API v3 [WIP] (#6144)

* Fixed more tests

* Added tags into user service

* Added api-v3 auth urls

* v3: fix package.json

* v3: fix package.json

* Fixed auth tests. Updated Authctrl response

* v3: remove newrelic config file in favour of env variables

* v3: upgrade some deps

* switch from Q to Bluebird

* v3 fix tests with deferred

* Removed extra consoles.log. Changed data.data to res.data

* v3 fix tests and use coroutines instead of regenerator

* v3: fix tests

* v3: do not await a non promise

* v3: q -> bluebird

* Changed id param for registration response

* Updated party query and create

* Ensured login callback happens after user sync

* Add challenges to groups. Fixed isMemberOfGuild check

* Updated party and group tests

* Fixed cron test

* return user.id and send analytics event before changing page

* fix trailing spaces

* disable redirects

* Api v3 party tavern fixes (#7191)

* Added check if user is in party before query

* Cached party query. Prevented party request when user is not in party. Updated Party create with no invites

* Update tavern ctrl to use new promise

* v3: misc fixes

* Api v3 task fixes (#7193)

* Update task view to use _id

* Added try catch to user service ops calls

* v3 client: saving after syncing is complete

* Fixed test broken by part sync change (#7195)

* v3: fix todo scoring and try to fix production testing problem

* revert changes to mongoose config

* mongoose: increase keepAlive

* test mongoose fix

* fix: Only apply captureStackTrace if it exists on the error object

* v3: fix reminders with no startDate

* mongoose: use options

* chore(): rename website/src -> website/server and website/public -> website/client (#7199)

* v3 fix GET /groups: return an error only if an invalid type is supplied not when there are 0 results (#7203)

* [API v3] Fix calls to user.ops and deleting tags (#7204)

* v3: fixes calls to user.ops from views and deleting tags

* v3: fix tests that use user._statsComputed

* Api v3 fixes continued (#7205)

* Added timzeone offset back

* Added APIToken back to settings page

* Fixed fetch recent messages for party

* Fixed returning group description

* Fixed check if user is member of challenge

* Fixed party members appearing in header

* Updated get myGroups param to include public groups. Fixed isMemberOf group

* Fixed hourglass purchase

* Fixed challenge addding tasks on first creating

* Updated tests to accomidate new changes

* fix: Correct checklist on client

Closes #7207

* fix: Pin eslint to 2.9

* minor improvements to cron code for clarity; fix inaccurate comments; add TODOs for rest-in-inn actions

* fix: Add missing type param to equip call

closes #7212

* rename and reword pubChalsMinPrize to reflect that it's only for Tavern challenges

* allows players to send gems to each other; other minor related changes - fixes https://github.com/HabitRPG/habitrpg/issues/7227

* fix tests for /members/transfer-gems

* fix: Set gems sent notification as translatable string

* chore: Remove unusued variable

* fix: Remove requirement on message paramter in transfer-gems

* add a missing variable declaration

* chore: clarify comments on cron code

* fix: Correct client request from habitrpg -> tavern

* update apidoc URL in package.json

Closes #7222

* Fixed start party by invites

* Updated spell casting to v3

* Fixed adding and removing tags on tasks

* Fixed page reload on settings change

* Fixed battle monsters with friends button

* Loaded completed todos when done is clicked

* chore: Reinstate floating version number for eslint

babel-eslint regression fixed

* Fixed reload tests

* change "an user" to "a user" in comments and text (no code changes) (#7257)

* fix: Alert user that drops were recieved

* remove userServices.js from karma.conf - it's been moved to website/client/js/services

* feat: Create debug update user route

* fix: Correct set cron debug function

* feat: Add make admin button to debug menu

* lint: Add missing semicolons in test

* fix: Temporarilly comment out udpate user debug route

* v3: fix _tmp for crit and streakBonus

* v3: execute all actions when leaving a solo party

* v3 client: fix group not found when leaving party

* v3 migration: fix challenge prize

* v3 cron: only save modified tasks

* v3: add CHALLENGE_TASK_NOT_FOUND to valid broken reasons

* v3: fix tasks chart

* v3 client: fix ability to leave challenge

* v3 client: fix filtering by tag and correctly show tag tooltip

* v3 common: fix tags tests

* v3 client: support unlinking not found challenges tasks

* v3: disable Bluebird warning for missing return, fixes #7269

* feat: Separate out update-user into set-cron and make-admin debug routes

* chore: Disable make admin debug route for v3 prod testing

* v3: misc fixes

* v3: misc fixes

* v3: fix adding multiple tasks

* Fixed join/leave button updates

* Queried only user groups to be available when creating challenges

* Fixed bulk add tasks to challenge

* Synced challenge tasks after leave and join.

* Fixed default selected group

* Fixed challenge member info. Fixed challenge winner selection

* Fixed deleting challenge tasks

* Fixed particiapting filter

* v3 client: fix casting spells

* v3: do not log sensitive data

* v3: always save user when casting spell

* v3: always save user when casting spell

* v3: more fixes for spells

* fix typos and missing information in apidocs - fixes https://github.com/HabitRPG/habitrpg/issues/7277 (#7282)

* v3: add TODO for client side spells

* feat: Add modify inventory debug menu

* Fixed viewing user progress on challenge

* Updated tests

* fix: Fix quest progress button

* fix incorrect Armoire test; remove unneeded param details from apidocs; disambiguate health potion

* v3: fix stealth casting

* v3: fix tasks saving and selection for rebirth reroll and reset (server-only)

* v3: fix auto allocation

* v3 client: misc fixes

* rename buyPotion and buy-potion to buyHealthPotion and buy-health-potion; fix apidoc param error

* Added delete for saved challenge task

* Fixed member modal on front page

* adjust text in apidocs for errors / clarity / consistency / standard terminology (no code changes) (#7298)

* fix bug in Rebirth test, add new tests, adjust apidocs (#7293)

* Updated task model to allow setting streak (#7306)

* fix: Correct missing * in apidoc comments

* Api v3 challenge fixes (#7287)

* Fixed join/leave button updates

* Queried only user groups to be available when creating challenges

* Fixed bulk add tasks to challenge

* Synced challenge tasks after leave and join.

* Fixed default selected group

* Fixed challenge member info. Fixed challenge winner selection

* Fixed deleting challenge tasks

* Fixed particiapting filter

* Fixed viewing user progress on challenge

* Updated tests

* Added delete for saved challenge task

* v3: fix sorting

* [API v3] add CRON_SAFE_MODE (#7286)

* add CRON_SAFE_MODE to example config file, fix some bugs, add an unrelated low-priority TODO

* create CRON_SAFE_MODE to disable parts of cron for use after extended outage - fixes https://github.com/HabitRPG/habitrpg/issues/7161

* fix a bug with CRON_SAFE_MODE, remove duplicated code, remove completed TODO comment

* fix check for CRON_SAFE_MODE

* v3 client: fix typo

* adjust debug menu Modify Inventory: hungrier pets, fewer Special items, "Hide" buttons

* completed To-Dos: return the 30 most recent instead of 30 oldest (#7318)

* v3 migration: fix createdAt date

* adjust locales text, key names, and files for Rebirth, Reset, and Fortify / ReRoll for consistency with existing strings (#7321)

* v3: fix unlinking multiple tasks

* v3 fix releasing pets

* v3: fix authenticating with apiUrl

* v3: fix typo

* v3 fix client tests for unlinking

* v3 client: do not show start quest button when quest is active

* v3 client: fix ability to send cards

* v3 client: fix misc challenge issues

* v3: fix notifications

* v3 client: more user friendly errors

* v3 client: only load completed todos once

* v3 client: fix tests

* v3: move TAVERN_ID to common code

* fix: Provide default type and text for new task creation in score route

* fix: Provide default history [] for habit in score route

* fix: Add _legacyId prop to tasks to support non-uuid identifiers

* chore: Change v3 migration to use _legacyId instead of legacyId

* fix: check for _legacyId in tasks if id does not exist

* refactor: Extract out finding task by id or _legacyId into a function

* Api v3 party quest fixes (#7341)

* Fix display of add challenge message when group challenges are empty

* Fixed forced quest start to update quest without reload

* Fixed needing to reload when accepting party invite

* Fix group leave and join reload

* Fixed leave current party and join another

* Updated party tests

* v3 client: remove console.log statement

* v3: misc fixes

* v3 client: fix predicatbale random

* v3: info about API v3

* v3: update footer with links to developer resources

* v3: support party invitation from email

* v3 client: fix chat flagging

* fix: Correct get tasks route to properly get todos (#7349)

* move locales strings from api-v3.json to other locales files (#7347)

* move locales strings from api-v3.json: authentication strings -> front.json

* move locales strings from api-v3.json: authentication strings -> tasks.json

* move locales strings from api-v3.json: authentication strings -> groups.json

* move locales strings from api-v3.json: authentication strings -> challenge.json

* move locales strings from api-v3.json: authentication strings -> groups.json (again)

* move locales strings from api-v3.json: authentication strings -> quests.json

* move locales strings from api-v3.json: authentication strings -> subscriber.json

* move locales strings from api-v3.json: authentication strings -> spells.json

* move locales strings from api-v3.json: authentication strings -> character.json

* move locales strings from api-v3.json: authentication strings -> groups.json (PMs)

* move locales strings from api-v3.json: authentication strings -> npc.json

* move locales strings from api-v3.json: authentication strings -> pets.json

* move locales strings from api-v3.json: authentication strings -> miscellaneous

* move locales strings from api-v3.json: authentication strings -> contrib.json and settings.json

* move locales strings from api-v3.json: delete unused string (invalidTasksOwner), delete api-v3.json, whitespace cleanup

* v3 client: fix sticky header

* v3: remove unused code

* v3 client: correctly redirect after inviting

* Removed v2 calls from views (#7351)

* v3: fix tests for challenge export

* v3: fallbackto authWithHeaders if wuthWithSession or authWithUrl fails

* Added force cache update when fetching new messages (#7360)

* v3: fetch whole user when booting from group tto avoid issues with pre save hook expecting all data

* v3: misc fixes for payments

* v3: limit fields of challenge tasks that can be updated

* fix(tests): never connect to NODE_DB_URI for tests

* Added new route for setting last cron and updated front end

* v3: fix iap url

* v3: fix build and ios IAP

* Changed route to user set custom day start

* v3: iap accessible under /api/v3, fixes to spells and groups invitations

* v3: correctly use v3 routes in client

* remove XP, GP when unticking a Daily with a completed checklist - fixes https://github.com/HabitRPG/habitrpg/issues/7246

* use natural language for error message about skills on challenge tasks (#7336), fix other gramatical error

* Updated ui when user rejects a guild invite (#7368)

* feat: complete custom day start route

Closes #7363

* fix: Correct spelling of healAll skill

fix: Correct sprite name of healAll skill

* fix: Change all instances of spookDust -> spookySparkles

* add dateCreated to all tasks; add empty challenge object to tasks that don't have one (#7386)

* add plumilla to artists for Tangle Tree in Bailey message

* Fixed quest drop modal (#7377)

* Fixed quest drop modal

* Fixed broken party test

* [API v3] Maintenance Mode (#7367)

* WIP(maintenance): maintenance

* WIP(maintenance): working locale features

* fix(maintenance): don't translate info page target

* WIP(maintenance): start adding info page

* fix(maintenance): linting

* feat: Add container to maintenance info page

* fix(maintenance): add config.json edits
Also DRY variables for main vs info pages

* fix(maintenance): linting

* refactor(maintenance): further slim down variables

* refactor: Remove unnecessary variables

* fix: Correct string interpolation in maintenace view

* feat: Dynamically add time to maintenance pages

* maintenance mode: do not connect to mongodb

* fix(maintenance): clean up timezones etc.

* fix(maintenance): remove unneeded sprite

* Tavern party challenges invites fix (#7394)

* Added challenges and invitations to party

* Loaded tavern challenges

* Updated group and quest services tests

* v3: implement automatic syncing if user is not up to date

* Removed unnecessary fields when updating groups and challenges (#7395)

* v3: do not saved populated user

* v3: correctly return user subset

* Chained party promises together (#7396)

* v3: $w -> splitWhitespace

* use bluebird

* use babel polyfill

* migration: fix items

* update links for v3

* Updated shortname validation to support multiple browsers

* Docs changes (#7401)

* chore: Clarify transfer-gems documentation

* chore: Clarify api status route documentation

* chore: Mark webhooks as BETA

* Added tags update route. Added sort to user service (#7381)

* Added tags update route. Added sort to user service

* Change update tasks route to reorder tasks

* Fixed linting issue

* Changed params for reorder tags route

* Fixed not found tag and added test

* Added password confirmation when deleteing account (#7402)

* fix production logging

* feat(commit): push

* empty commit

* feat(maintenance): post-downtime news & awards (#7406)

* fix exporting avatar

* second attempt at fixing exporting avatar

* fix production logging

* s3: convert moment to date instance

* fix avatar sharing and caching (30 minutes)

* fix: Correct missing parameter

Closes #7433

* fix: Validate challenge shortname on server

* adjust text strings - fixes https://github.com/HabitRPG/habitrpg/issues/5631 and also Short Name -> Tag Name
This commit is contained in:
Matteo Pagliazzi
2016-05-23 13:58:31 +02:00
parent ef3a2fc286
commit 28f2e9c356
993 changed files with 44888 additions and 12883 deletions
+385
View File
@@ -0,0 +1,385 @@
var _ = require('lodash');
var validator = require('validator');
var passport = require('passport');
var shared = require('../../../../common');
var async = require('async');
var utils = require('../../libs/api-v2/utils');
var nconf = require('nconf');
var request = require('request');
var FirebaseTokenGenerator = require('firebase-token-generator');
import {
model as User,
} from '../../models/user';
import {
model as EmailUnsubscription,
} from '../../models/emailUnsubscription';
var analytics = utils.analytics;
var i18n = require('./../../libs/api-v2/i18n');
var isProd = nconf.get('NODE_ENV') === 'production';
var api = module.exports;
var NO_TOKEN_OR_UID = { err: shared.i18n.t('messageAuthMustIncludeTokens') };
var NO_USER_FOUND = {err: shared.i18n.t('messageAuthNoUserFound') };
var NO_SESSION_FOUND = { err: shared.i18n.t('messageAuthMustBeLoggedIn') };
var accountSuspended = function(uuid){
return {
err: 'Account has been suspended, please contact leslie@habitica.com with your UUID ('+uuid+') for assistance.',
code: 'ACCOUNT_SUSPENDED'
};
}
api.auth = function(req, res, next) {
var uid = req.headers['x-api-user'];
var token = req.headers['x-api-key'];
if (!(uid && token)) return res.status(401).json(NO_TOKEN_OR_UID);
User.findOne({_id: uid, apiToken: token}, function(err, user) {
if (err) return next(err);
if (_.isEmpty(user)) return res.status(401).json(NO_USER_FOUND);
if (user.auth.blocked) return res.status(401).json(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.status(401).json(NO_SESSION_FOUND);
User.findOne({_id: req.session.userId}, function(err, user) {
if (err) return next(err);
if (_.isEmpty(user)) return res.status(401).json(NO_USER_FOUND);
res.locals.user = user;
next();
});
};
// TODO passing auth params as query params is not safe as they are logged by browser history, ...
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.status(401).json(NO_USER_FOUND);
res.locals.user = user;
next();
});
}
api.registerUser = function(req, res, next) {
var email = req.body.email && req.body.email.toLowerCase();
var username = req.body.username;
// Get the lowercase version of username to check that we do not have duplicates
// So we can search for it in the database and then reject the choosen username if 1 or more results are found
var lowerCaseUsername = username && username.toLowerCase();
async.auto({
validate: function(cb) {
if (!(username && req.body.password && email))
return cb({code:401, err: shared.i18n.t('messageAuthCredentialsRequired')});
if (req.body.password !== req.body.confirmPassword)
return cb({code:401, err: shared.i18n.t('messageAuthPasswordMustMatch')});
if (!validator.isEmail(email))
return cb({code:401, err: ":email invalid"});
cb();
},
findReg: function(cb) {
// Search for duplicates using lowercase version of username
User.findOne({$or:[{'auth.local.email': email}, {'auth.local.lowerCaseUsername': lowerCaseUsername}]}, {'auth.local':1}, cb);
},
findFacebook: function(cb){
User.findOne({_id: req.headers['x-api-user'], apiToken: req.headers['x-api-key']}, {auth:1}, cb);
},
register: ['validate', 'findReg', 'findFacebook', function(cb, data) {
if (data.findReg) {
if (email === data.findReg.auth.local.email) return cb({code:401, err:"Email already taken"});
// Check that the lowercase username isn't already used
if (lowerCaseUsername === data.findReg.auth.local.lowerCaseUsername) return cb({code:401, err: shared.i18n.t('messageAuthUsernameTaken')});
}
var salt = utils.makeSalt();
var newUser = {
auth: {
local: {
username: username,
lowerCaseUsername: lowerCaseUsername, // Store the lowercase version of the username
email: email, // Store email as lowercase
salt: salt,
hashed_password: utils.encryptPassword(req.body.password, salt)
},
timestamps: {created: +new Date(), loggedIn: +new Date()}
}
};
// existing user, allow them to add local authentication
if (data.findFacebook) {
data.findFacebook.auth.local = newUser.auth.local;
data.findFacebook.registeredThrough = newUser.registeredThrough;
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);
user.registeredThrough = req.headers['x-client'];
var analyticsData = {
category: 'acquisition',
type: 'local',
gaLabel: 'local',
uuid: user._id,
};
analytics.track('register', analyticsData)
user.save(function(err, savedUser){
if (err) return cb(err);
// Clean previous email preferences
EmailUnsubscription.remove({email: savedUser.auth.local.email}, function(){
utils.txnEmail(savedUser, 'welcome');
});
cb.apply(cb, arguments);
});
}
}]
}, function(err, data) {
if (err) return err.code ? res.status(err.code).json(err) : next(err);
data.register[0].getTransformedData(function(err, userTransformed){
if(err) return next(err);
res.status(200).json(userTransformed);
});
});
};
api.loginLocal = function(req, res, next) {
var username = req.body.username;
var password = req.body.password;
if (!(username && password)) return res.status(401).json({err:'Missing :username or :password in request body, please provide both'});
var login = validator.isEmail(username) ?
{'auth.local.email':username.toLowerCase()} : // Emails are all lowercase
{'auth.local.username':username}; // Use the username as the user typed it
User.findOne(login, {auth:1}, function(err, user){
if (err) return next(err);
if (!user) return res.status(401).json({err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\" on the habitica.com website's login form."});
if (user.auth.blocked) return res.status(401).json(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.status(401).json({err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\" on the habitica.com website's login form."});
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.status(401).json({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.registeredThrough = req.headers['x-client'];
user.save(function(err, savedUser){
// Clean previous email preferences
if(savedUser.auth.facebook.emails && savedUser.auth.facebook.emails[0] && savedUser.auth.facebook.emails[0].value){
EmailUnsubscription.remove({email: savedUser.auth.facebook.emails[0].value}, function(){
utils.txnEmail(savedUser, 'welcome');
});
}
cb.apply(cb, arguments);
});
var analyticsData = {
category: 'acquisition',
type: network,
gaLabel: network,
uuid: user._id,
};
analytics.track('register', analyticsData)
}]
}, function(err, results){
if (err) return res.status(401).json({err: err.toString ? err.toString() : err});
var acct = results.register[0] ? results.register[0] : results.register;
if (acct.auth.blocked) return res.status(401).json(accountSuspended(acct._id));
return res.status(200).json({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.status(401).json({err:"Account lacks another authentication method, can't detach Facebook"});
//TODO 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.sendStatus(200);
})
}
api.resetPassword = function(req, res, next){
var email = req.body.email && req.body.email.toLowerCase(), // Emails are all lowercase
salt = utils.makeSalt(),
newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later)
hashed_password = utils.encryptPassword(newPassword, salt);
if(!email) return res.status(400).json({err: "Email not provided"});
User.findOne({'auth.local.email': email}, function(err, user){
if (err) return next(err);
if (!user) return res.status(401).json({err:"Sorry, we can't find a user registered with email " + email + "\n- Make sure your email address is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login."});
user.auth.local.salt = salt;
user.auth.local.hashed_password = hashed_password;
utils.sendEmail({
from: "Habitica <admin@habitica.com>",
to: email,
subject: "Password Reset for Habitica",
text: "Password for " + user.auth.local.username + " has been reset to " + newPassword + " Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at " + nconf.get('BASE_URL') + ". After you've logged in, head to " + nconf.get('BASE_URL') + "/#/options/settings/settings and change your password.",
html: "Password for <strong>" + user.auth.local.username + "</strong> has been reset to <strong>" + newPassword + "</strong><br /><br />Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.<br /><br />Log in at " + nconf.get('BASE_URL') + ". After you've logged in, head to " + nconf.get('BASE_URL') + "/#/options/settings/settings and change your password."
});
user.save(function(err){
if(err) return next(err);
res.send('New password sent to '+ email);
email = salt = newPassword = hashed_password = null;
});
});
};
var invalidPassword = function(user, password){
var hashed_password = utils.encryptPassword(password, user.auth.local.salt);
if (hashed_password !== user.auth.local.hashed_password)
return {code:401, err:"Incorrect password"};
return false;
}
api.changeUsername = function(req, res, next) {
var user = res.locals.user;
var username = req.body.username;
var lowerCaseUsername = username && username.toLowerCase(); // we search for the lowercased version to intercept duplicates
if(!username) return res.status(400).json({err: "Username not provided"});
async.waterfall([
function(cb){
User.findOne({'auth.local.lowerCaseUsername': lowerCaseUsername}, {auth:1}, cb);
},
function(found, cb){
if (found) return cb({code:401, err: "Username already taken"});
if (invalidPassword(user, req.body.password)) return cb(invalidPassword(user, req.body.password));
user.auth.local.username = username;
user.auth.local.lowerCaseUsername = lowerCaseUsername;
user.save(cb);
}
], function(err){
if (err) return err.code ? res.status(err.code).json(err) : next(err);
res.sendStatus(200);
})
}
api.changeEmail = function(req, res, next){
var email = req.body.email && req.body.email.toLowerCase(); // emails are all lowercase
if(!email) return res.status(400).json({err: "Email not provided"});
async.waterfall([
function(cb){
User.findOne({'auth.local.email': email}, {auth:1}, cb);
},
function(found, cb){
if(found) return cb({code:401, err: shared.i18n.t('messageAuthEmailTaken')});
if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password));
res.locals.user.auth.local.email = email;
res.locals.user.save(cb);
}
], function(err){
if (err) return err.code ? res.status(err.code).json(err) : next(err);
res.sendStatus(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.status(401).json({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.status(401).json({err:"Old password doesn't match"});
user.auth.local.hashed_password = hashed_new_password;
user.save(function(err, saved){
if (err) next(err);
res.sendStatus(200);
})
};
// DISABLED FOR API v2
/*var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
api.getFirebaseToken = function(req, res, next) {
var user = res.locals.user;
// Expires 24 hours after now (60*60*24*1000) (in milliseconds)
var expires = new Date();
expires.setTime(expires.getTime() + 86400000);
var token = firebaseTokenGeneratorInstance
.createToken({
uid: user._id,
isHabiticaUser: true
}, {
expires: expires
});
res.status(200).json({
token: token,
expires: expires
});
};*/
// DISABLED FOR API v2
/*api.setupPassport = function(router) {
router.get('/logout', i18n.getUserLanguage, function(req, res) {
req.logout();
delete req.session.userId;
res.redirect('/');
})
};*/
@@ -0,0 +1,428 @@
// @see ../routes for routing
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var shared = require('../../../../common');
import {
model as User,
} from '../../models/user';
import {
model as Group,
basicFields as basicGroupFields,
TAVERN_ID,
} from '../../models/group';
import {
model as Challenge,
} from '../../models/challenge';
import * as Tasks from '../../models/task';
var logging = require('./../../libs/api-v2/logging');
var csvStringify = require('csv-stringify');
var utils = require('../../libs/api-v2/utils');
var api = module.exports;
var pushNotify = require('./pushNotifications');
import Bluebird from 'bluebird';
import v3MembersController from '../api-v3/members';
/*
------------------------------------------------------------------------
Challenges
------------------------------------------------------------------------
*/
var nameFields = 'profile.name';
api.list = async function(req, res, next) {
try {
var user = res.locals.user;
let challenges = await Challenge.find({
$or: [
{_id: {$in: user.challenges}}, // Challenges where the user is participating
{group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member
{leader: user._id}, // Challenges where I'm the leader
],
_id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit
})
.sort('-official -timestamp')
// .populate('group', basicGroupFields)
// .populate('leader', nameFields)
.exec();
let resChals = challenges.map(challenge => {
let obj = challenge.toJSON();
obj._isMember = user.challenges.indexOf(challenge._id) !== -1;
return obj;
});
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
await Bluebird.all(resChals.map((chal, index) => {
return Bluebird.all([
User.findById(chal.leader).select(nameFields).exec(),
Group.findById(chal.group).select(basicGroupFields).exec(),
]).then(populatedData => {
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null;
});
}));
res.json(resChals);
} catch (err) {
next(err);
}
}
// GET
api.get = async function(req, res, next) {
try {
let user = res.locals.user;
let challengeId = req.params.cid;
let challenge = await Challenge.findById(challengeId)
// Don't populate the group as we'll fetch it manually later
// .populate('leader', nameFields)
.exec();
if (!challenge) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'});
// Fetching basic group data
let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true});
if (!group || !challenge.canView(user, group)) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'});
let leaderRes = await User.findById(challenge.leader).select('profile.name').exec();
leaderRes = leaderRes ? leaderRes.toJSON({minimize: true}) : null;
challenge.getTransformedData({
populateMembers: 'profile.name',
cb (err, transformedChal) {
transformedChal.group = group.toJSON({minimize: true});
transformedChal.leader = leaderRes;
transformedChal._isMember = user.challenges.indexOf(transformedChal._id) !== -1;
res.json(transformedChal);
}
});
} catch (err) {
next(err);
}
}
api.csv = function(req, res, next) {
var cid = req.params.cid;
req.params.challengeId = cid;
v3MembersController.exportChallengeCsv.handler(req, res, next).catch(next);
}
api.getMember = function(req, res, next) {
var cid = req.params.cid;
var uid = req.params.uid;
req.params.memberId = uid;
req.params.challengeId = cid;
v3MembersController.getChallengeMemberProgress.handler(req, res, next)
.then(result => {
let newResult = {
profile: {
name: result.profile.name,
},
habits: [],
dailys: [],
todos: [],
rewards: [],
};
let tasks = result.tasks;
tasks.forEach(task => {
let taskObj = task.toJSONV2();
newResult[taskObj.type + 's'].push(taskObj);
});
res.json(newResult);
})
.catch(next);
}
// CREATE
api.create = async function(req, res, next){
try {
var user = res.locals.user;
let groupId = req.body.group;
let prize = req.body.prize;
let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
if (!group) return res.status(404).json({err:"Group." + req.body.group + " not found"});
if (!group.isMember(user)) return res.status(404).json({err:"Group." + req.body.group + " not found"});
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
return res.status(401).json({err:"Only the group leader can create challenges"});
}
if (group._id === TAVERN_ID && prize < 1) {
return res.status(401).json({err: 'Prize must be at least 1 Gem for public challenges.'})
}
if (prize > 0) {
let groupBalance = group.balance && group.leader === user._id ? group.balance : 0;
let prizeCost = prize / 4;
if (prizeCost > user.balance + groupBalance) {
return res.status(401).json({err: '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
let remainder = prizeCost - group.balance;
group.balance = 0;
user.balance -= remainder;
} else {
// User pays for all of prize
user.balance -= prizeCost;
}
}
group.challengeCount += 1;
req.body.leader = user._id;
req.body.official = user.contributor.admin && req.body.official;
let challenge = new Challenge(Challenge.sanitize(req.body));
// First validate challenge so we don't save group if it's invalid (only runs sync validators)
let challengeValidationErrors = challenge.validateSync();
if (challengeValidationErrors) throw challengeValidationErrors;
req.body.habits = req.body.habits || [];
req.body.todos = req.body.todos || [];
req.body.dailys = req.body.dailys || [];
req.body.rewards = req.body.rewards || [];
var chalTasks = req.body.habits.concat(req.body.rewards)
.concat(req.body.dailys).concat(req.body.todos)
.map(v2Task => Tasks.Task.fromJSONV2(v2Task));
chalTasks = chalTasks.map(function(task) {
var newTask = new Tasks[task.type](Tasks.Task.sanitize(task));
newTask.challenge.id = challenge._id;
return newTask.save();
});
let results = await Bluebird.all([challenge.save({
validateBeforeSave: false, // already validated
}), group.save()].concat(chalTasks));
let savedChal = results[0];
await savedChal.syncToUser(user); // (it also saves the user)
savedChal.getTransformedData({
cb (err, transformedChal) {
res.status(201).json(transformedChal);
},
});
} catch (err) {
next(err);
}
}
// UPDATE
api.update = function(req, res, next){
var cid = req.params.cid;
var user = res.locals.user;
var before;
var updatedTasks;
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(chal, cb){
if(!chal) return cb({chal: null});
chal.getTasks(function(err, tasks){
cb(err, {
chal: chal,
tasks: tasks
});
});
},
function(_before, cb) {
if (!_before.chal) return cb('Challenge ' + cid + ' not found');
if (_before.chal.leader != user._id && !user.contributor.admin) return cb({code: 401, err: shared.i18n.t('noPermissionEditChallenge', req.language)});
// Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some
// before-save / after-save comparison to determine if we need to sync to users
before = {chal: _before.chal, tasks: _before.tasks};
var chalAttrs = _.pick(req.body, 'name shortName description date'.split(' '));
async.parallel({
chal: function(cb1){
Challenge.findByIdAndUpdate(cid, {$set:chalAttrs}, {new: true}, cb1);
},
tasks: function(cb1) {
// Convert to map of {id: task} so we can easily match them
var _beforeClonedTasks = _before.tasks;
updatedTasks = _.object(_.pluck(_beforeClonedTasks, '_id'), _beforeClonedTasks);
var newTasks = req.body.habits.concat(req.body.dailys)
.concat(req.body.todos).concat(req.body.rewards);
var newTasksObj = _.object(_.pluck(newTasks, '_id'), newTasks);
async.forEachOf(newTasksObj, function(newTask, taskId, cb2){
// some properties can't be changed
newTask = Tasks.Task.sanitize(newTask);
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
_.assign(updatedTasks[taskId], shared.ops.updateTask(updatedTasks[taskId].toObject(), {body: newTask}));
_before.chal.updateTask(updatedTasks[taskId]).then(cb2).catch(cb2);
}, cb1);
}
}, cb);
},
], function(err, saved){
if(err) {
return err.code ? res.json(err.code, err) : next(err);
}
saved.chal.getTransformedData({cb: function(err, newChal){
if(err) return next(err);
res.json(newChal);
}})
cid = user = before = null;
});
}
/**
* Delete & close
*/
api.delete = async function(req, res, next){
try {
var user = res.locals.user;
var cid = req.params.cid;
let challenge = await Challenge.findOne({_id: req.params.cid}).exec();
if (!challenge) return next('Challenge ' + cid + ' not found');
if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge'));
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
res.sendStatus(200);
} catch (err) {
next(err);
}
}
/**
* Select Winner & Close
*/
api.selectWinner = async function(req, res, next) {
try {
if (!req.query.uid) return res.status(401).json({err: 'Must select a winner'});
let challenge = await Challenge.findOne({_id: req.params.cid}).exec();
if (!challenge) return next('Challenge ' + req.params.cid + ' not found');
if (!challenge.canModify(res.locals.user)) return next(shared.i18n.t('noPermissionCloseChallenge'));
let winner = await User.findOne({_id: req.query.uid}).exec();
if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.');
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
res.respond(200, {});
} catch (err) {
next(err);
}
}
api.join = async function(req, res, next){
try {
var user = res.locals.user;
var cid = req.params.cid;
let challenge = await Challenge.findOne({ _id: cid });
if (!challenge) return next(shared.i18n.t('challengeNotFound'));
if (challenge.isMember(user)) return next(shared.i18n.t('userAlreadyInChallenge'));
let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true});
if (!group || !challenge.hasAccess(user, group)) return next(shared.i18n.t('challengeNotFound'));
challenge.memberCount += 1;
// Add all challenge's tasks to user's tasks and save the challenge
await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
challenge.getTransformedData({
cb (err, transformedChal) {
transformedChal._isMember = true;
res.json(transformedChal);
}
});
} catch (e) {
next(e);
}
}
api.leave = async function(req, res, next){
try {
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';
let challenge = await Challenge.findOne({ _id: cid });
if (!challenge) return next(shared.i18n.t('challengeNotFound'));
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'});
if (!group || !challenge.canView(user, group)) return next(shared.i18n.t('challengeNotFound'));
if (!challenge.isMember(user)) return next(shared.i18n.t('challengeMemberNotFound'));
challenge.memberCount -= 1;
// Unlink challenge's tasks from user's tasks and save the challenge
await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]);
challenge.getTransformedData({
cb (err, transformedChal) {
transformedChal._isMember = false;
res.json(transformedChal);
}
});
} catch (e) {
next(e);
}
}
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
api.unlink = async function(req, res, next) {
try {
var user = res.locals.user;
var tid = req.params.id;
var cid;
if (!req.query.keep)
return res.status(400).json({err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'});
let keep = req.query.keep;
let task = await Tasks.Task.findOne({
_id: tid,
userId: user._id,
}).exec();
if (!task) return next(shared.i18n.t('taskNotFound'));
if (!task.challenge.id) return next(shared.i18n.t('cantOnlyUnlinkChalTask'));
cid = task.challenge.id;
if (keep === 'keep') {
task.challenge = {};
await task.save();
} else { // remove
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
removeFromArray(user.tasksOrder[`${task.type}s`], tid);
await Bluebird.all([user.save(), task.remove()]);
} else {
await task.remove();
}
}
res.sendStatus(200);
} catch (e) {
next(e);
}
}
@@ -0,0 +1,47 @@
var _ = require('lodash');
import {
model as Coupon,
} from '../../models/coupon';
var api = module.exports;
var csvStringify = require('csv-stringify');
var async = require('async');
api.ensureAdmin = function(req, res, next) {
if (!res.locals.user.contributor.sudo) return res.status(401).json({err:"You don't have admin access"});
next();
}
api.generateCoupons = function(req,res,next) {
let count = Number(req.query.count);
Coupon.generate(req.params.event, count, function(err){
if(err) return next(err);
res.sendStatus(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){
let output = [['code']].concat(_.map(coupons, function(c){
return [c._id];
}))
res.set({
'Content-Type': 'text/csv',
'Content-disposition': 'attachment; filename=habitica-coupons.csv',
});
csvStringify(output, (err, csv) => {
if (err) return next(err);
res.status(200).send(csv);
});
});
}
api.enterCode = function(req,res,next) {
Coupon.apply(res.locals.user,req.params.code,function(err,user){
if (err) return res.status(400).json({err:err});
res.json(user);
});
}
@@ -0,0 +1,153 @@
var _ = require('lodash');
var express = require('express');
var csvStringify = require('csv-stringify');
var nconf = require('nconf');
var moment = require('moment');
var js2xmlparser = require("js2xmlparser");
var pd = require('pretty-data').pd;
import {
model as User,
} from '../../models/user';
// 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
------------------------------------------------------------------------
*/
var dataexport = module.exports;
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
]);
});
});
res.set({
'Content-Type': 'text/csv',
'Content-disposition': 'attachment; filename=habitica-tasks-history.csv',
});
csvStringify(output, (err, csv) => {
if (err) return next(err);
res.status(200).send(csv);
});
};
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.status(500).json({err: err});
if (_.isEmpty(user)) return res.status(401).json(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 = 'avatars/'+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()
.then(function (file) {
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);
}).catch(next);
})
};
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var shared = require('../../../../common');
import {
model as User,
} from '../../models/user';
import {
model as Group,
} from '../../models/group';
var api = module.exports;
api.ensureAdmin = function(req, res, next) {
var user = res.locals.user;
if (!(user.contributor && user.contributor.admin)) return res.status(401).json({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.status(400).json({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.status(404).json({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.status(204).json({});
})
}
@@ -0,0 +1,139 @@
import {
model as groups,
chatDefaults,
} from '../../models/group';
import {
model as User,
} from '../../models/user';
let partyFields = require('./groups').partyFields;
var api = module.exports;
var async = require('async');
var _ = require('lodash');
var shared = require('../../../../common');
var utils = require('../../libs/api-v2/utils');
var nconf = require('nconf');
var pushNotify = require('./pushNotifications');
var fetchMember = function(uuid, restrict){
return function(cb){
var q = User.findById(uuid);
if (restrict) q.select(partyFields);
q.exec(function(err, member){
if (err) return cb(err);
if (!member) return cb({code:404, err: 'User not found'});
return cb(null, member);
})
}
}
var sendErr = function(err, res, next){
err.code ? res.status(err.code).json({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 ";
if (data.type == 'gems') {
var gemAmount = data.gems.amount;
var gemLabel = gemAmount > 1 ? "gems" : "gem";
msg += gemAmount + " " + gemLabel + "!`";
} else {
var monthAmount = shared.content.subscriptionBlocks[data.subscription.key].months;
var monthLabel = monthAmount > 1 ? "months" : "month";
msg += monthAmount + " " + monthLabel + " of subscription!`";
}
msg += data.message ? data.message : '';
}
shared.refPush(member.inbox.messages, chatDefaults(msg, user));
member.inbox.newMessages++;
member._v++;
member.markModified('inbox.messages');
shared.refPush(user.inbox.messages, _.defaults({sent:true}, chatDefaults(msg, member)));
user.markModified('inbox.messages');
}
api.sendPrivateMessage = function(req, res, next){
var fetchedMember;
async.waterfall([
fetchMember(req.params.uuid),
function(member, cb) {
fetchedMember = member;
if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me
|| ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them
|| member.inbox.optOut) { // or if they've opted out of messaging
return cb({code: 401, err: "Can't send message to this user."});
}
api.sendMessage(res.locals.user, member, {message:req.body.message});
async.parallel([
function (cb2) { member.save(cb2) },
function (cb2) { res.locals.user.save(cb2) }
], cb);
}
], function(err){
if (err) return sendErr(err, res, next);
if(fetchedMember.preferences.emailNotifications.newPM !== false){
utils.txnEmail(fetchedMember, 'new-pm', [
{name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name},
{name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'}
]);
}
res.sendStatus(200);
})
}
api.sendGift = function(req, res, next){
async.waterfall([
fetchMember(req.params.uuid),
function(member, cb) {
// Gems
switch (req.body.type) {
case "gems":
var amt = req.body.gems.amount / 4,
user = res.locals.user;
if (member.id == user.id)
return cb({code: 401, err: "Cannot send gems to yourself. Try a subscription instead."});
if (!amt || amt <=0 || user.balance < amt)
return cb({code: 401, err: "Amount must be within 0 and your current number of gems."});
member.balance += amt;
user.balance -= amt;
api.sendMessage(user, member, req.body);
var byUsername = utils.getUserInfo(user, ['name']).name;
if(member.preferences.emailNotifications.giftedGems !== false){
utils.txnEmail(member, 'gifted-gems', [
{name: 'GIFTER', content: byUsername},
{name: 'X_GEMS_GIFTED', content: req.body.gems.amount}
]);
}
pushNotify.sendNotify(member, shared.i18n.t('giftedGems'), shared.i18n.t('giftedGemsInfo', { amount: req.body.gems.amount, name: byUsername }));
return async.parallel([
function (cb2) { member.save(cb2) },
function (cb2) { user.save(cb2) }
], cb);
case "subscription":
return cb();
default:
return cb({code:400, err:"Body must contain a gems:{amount,fromBalance} or subscription:{months} object"});
}
}
], function(err) {
if (err) return sendErr(err, res, next);
res.sendStatus(200);
});
}
@@ -0,0 +1,58 @@
// TODO move to /api-v2
var api = module.exports;
var _ = require('lodash');
var nconf = require('nconf');
var pushNotify = require('push-notify');
var gcmApiKey = nconf.get("PUSH_CONFIGS:GCM_SERVER_API_KEY");
var gcm = gcmApiKey ? pushNotify.gcm({
apiKey: gcmApiKey,
retries: 3
}) : undefined;
if(gcm){
gcm.on('transmitted', function (result, message, registrationId) {
//console.info("transmitted", result, message, registrationId);
});
gcm.on('transmissionError', function (error, message, registrationId) {
//console.info("transmissionError", error, message, registrationId);
});
gcm.on('updated', function (result, registrationId) {
//console.info("updated", result, registrationId);
});
}
api.sendNotify = function(user, title, msg, timeToLive){
timeToLive = timeToLive || 15;
// need investigation:
// https://github.com/HabitRPG/habitrpg/issues/5252
if(!user)
return;
_.forEach(user.pushDevices, function(pushDevice){
switch(pushDevice.type){
case "android":
if(gcm){
gcm.send({
registrationId: pushDevice.regId,
//collapseKey: 'COLLAPSE_KEY',
delayWhileIdle: true,
timeToLive: timeToLive,
data: {
title: title,
message: msg
}
});
}
break;
case "ios":
break;
}
});
};
@@ -0,0 +1,40 @@
import {
model as User,
} from '../../models/user';
import {
model as EmailUnsubscription,
} from '../../models/emailUnsubscription';
var utils = require('../../libs/api-v2/utils');
var i18n = require('../../../../common').i18n;
var api = module.exports = {};
api.unsubscribe = function(req, res, next){
if(!req.query.code) return res.status(500).json({err: 'Missing unsubscription code.'});
var data = JSON.parse(utils.decrypt(req.query.code));
if(data._id){
User.update({_id: data._id}, {
$set: {'preferences.emailNotifications.unsubscribeFromAll': true}
}, {multi: false}, function(err, updateRes){
if(err) return next(err);
if(updateRes.n !== 1) return res.json(404, {err: 'User not found'});
res.send('<h1>' + i18n.t('unsubscribedSuccessfully', null, req.language) + '</h1>' + i18n.t('unsubscribedTextUsers', null, req.language));
});
}else{
EmailUnsubscription.findOne({email: data.email}, function(err, doc){
if(err) return next(err);
var okRes = '<h1>' + i18n.t('unsubscribedSuccessfully', null, req.language) + '</h1>' + i18n.t('unsubscribedTextOthers', null, req.language);
if(doc) return res.send(okRes);
EmailUnsubscription.create({email: data.email}, function(err, doc){
if(err) return next(err);
res.send(okRes);
})
});
}
};
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
import validator from 'validator';
import moment from 'moment';
import passport from 'passport';
import nconf from 'nconf';
import {
authWithHeaders,
} from '../../middlewares/api-v3/auth';
import {
NotAuthorized,
BadRequest,
NotFound,
} from '../../libs/api-v3/errors';
import Bluebird from 'bluebird';
import * as passwordUtils from '../../libs/api-v3/password';
import logger from '../../libs/api-v3/logger';
import { model as User } from '../../models/user';
import { model as Group } from '../../models/group';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
import { decrypt } from '../../libs/api-v3/encryption';
import FirebaseTokenGenerator from 'firebase-token-generator';
import { send as sendEmail } from '../../libs/api-v3/email';
let api = {};
// When the user signed up after having been invited to a group, invite them automatically to the group
async function _handleGroupInvitation (user, invite) {
// wrapping the code in a try because we don't want it to prevent the user from signing up
// that's why errors are not translated
try {
let {sentAt, id: groupId, inviter} = JSON.parse(decrypt(invite));
// check that the invite has not expired (after 7 days)
if (sentAt && moment().subtract(7, 'days').isAfter(sentAt)) {
let err = new Error('Invite expired.');
err.privateData = invite;
throw err;
}
let group = await Group.getGroup({user, optionalMembership: true, groupId, fields: 'name type'});
if (!group) throw new NotFound('Group not found.');
if (group.type === 'party') {
user.invitations.party = {id: group._id, name: group.name, inviter};
} else {
user.invitations.guilds.push({id: group._id, name: group.name, inviter});
}
} catch (err) {
logger.error(err);
}
}
/**
* @api {post} /api/v3/user/auth/local/register Register
* @apiDescription Register a new user with email, username and password or attach local auth to a social user
* @apiVersion 3.0.0
* @apiName UserRegisterLocal
* @apiGroup User
*
* @apiParam {String} username Body parameter - Username of the new user
* @apiParam {String} email Body parameter - Email address of the new user
* @apiParam {String} password Body parameter - Password for the new user
* @apiParam {String} confirmPassword Body parameter - Password confirmation
*
* @apiSuccess {Object} data The user object, if local auth was just attached to a social user then only user.auth.local
*/
api.registerLocal = {
method: 'POST',
middlewares: [authWithHeaders(true)],
url: '/user/auth/local/register',
async handler (req, res) {
let fbUser = res.locals.user; // If adding local auth to social user
req.checkBody({
email: {
notEmpty: {errorMessage: res.t('missingEmail')},
isEmail: {errorMessage: res.t('notAnEmail')},
},
username: {notEmpty: {errorMessage: res.t('missingUsername')}},
password: {
notEmpty: {errorMessage: res.t('missingPassword')},
equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')},
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let { email, username, password } = req.body;
// Get the lowercase version of username to check that we do not have duplicates
// So we can search for it in the database and then reject the choosen username if 1 or more results are found
email = email.toLowerCase();
let lowerCaseUsername = username.toLowerCase();
// Search for duplicates using lowercase version of username
let user = await User.findOne({$or: [
{'auth.local.email': email},
{'auth.local.lowerCaseUsername': lowerCaseUsername},
]}, {'auth.local': 1}).exec();
if (user) {
if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken'));
// Check that the lowercase username isn't already used
if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken'));
}
let salt = passwordUtils.makeSalt();
let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase
let newUser = {
auth: {
local: {
username,
lowerCaseUsername,
email,
salt,
hashed_password, // eslint-disable-line camelcase
},
},
preferences: {
language: req.language,
},
};
if (fbUser) {
if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
fbUser.auth.local = newUser.auth.local;
newUser = fbUser;
} else {
newUser = new User(newUser);
newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used
}
// we check for partyInvite for backward compatibility
if (req.query.groupInvite || req.query.partyInvite) {
await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite);
}
let savedUser = await newUser.save();
if (savedUser.auth.facebook.id) {
res.respond(200, savedUser.toJSON().auth.local); // We convert to toJSON to hide private fields
} else {
res.respond(201, savedUser);
}
// Clean previous email preferences and send welcome email
EmailUnsubscription
.remove({email: savedUser.auth.local.email})
.then(() => sendTxnEmail(savedUser, 'welcome'));
if (!savedUser.auth.facebook.id) {
res.analytics.track('register', {
category: 'acquisition',
type: 'local',
gaLabel: 'local',
uuid: savedUser._id,
});
}
return null;
},
};
function _loginRes (user, req, res) {
if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id}));
return res.respond(200, {id: user._id, apiToken: user.apiToken});
}
/**
* @api {post} /api/v3/user/auth/local/login Login
* @apiDescription Login a user with email / username and password
* @apiVersion 3.0.0
* @apiName UserLoginLocal
* @apiGroup User
*
* @apiParam {String} username Body parameter - Username or email of the user
* @apiParam {String} password Body parameter - The user's password
*
* @apiSuccess {String} data._id The user's unique identifier
* @apiSuccess {String} data.apiToken The user's api token that must be used to authenticate requests.
*/
api.loginLocal = {
method: 'POST',
url: '/user/auth/local/login',
middlewares: [],
async handler (req, res) {
req.checkBody({
username: {
notEmpty: true,
errorMessage: res.t('missingUsernameEmail'),
},
password: {
notEmpty: true,
errorMessage: res.t('missingPassword'),
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
req.sanitizeBody('username').trim();
req.sanitizeBody('password').trim();
let login;
let username = req.body.username;
if (validator.isEmail(username)) {
login = {'auth.local.email': username.toLowerCase()}; // Emails are stored lowercase
} else {
login = {'auth.local.username': username};
}
let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec();
let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt);
if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentialsLong'));
return _loginRes(user, ...arguments);
},
};
function _passportFbProfile (accessToken) {
return new Bluebird((resolve, reject) => {
passport._strategies.facebook.userProfile(accessToken, (err, profile) => {
if (err) {
reject(err);
} else {
resolve(profile);
}
});
});
}
// Called as a callback by Facebook (or other social providers). Internal route
api.loginSocial = {
method: 'POST',
url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2
async handler (req, res) {
let accessToken = req.body.authResponse.access_token;
let network = req.body.network;
if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported'));
let profile = await _passportFbProfile(accessToken);
let user = await User.findOne({
[`auth.${network}.id`]: profile.id,
}, {_id: 1, apiToken: 1, auth: 1}).exec();
// User already signed up
if (user) {
_loginRes(user, ...arguments);
} else { // Create new user
user = new User({
auth: {
[network]: profile,
},
preferences: {
language: req.language,
},
});
user.registeredThrough = req.headers['x-client'];
let savedUser = await user.save();
_loginRes(user, ...arguments);
// Clean previous email preferences
if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) {
EmailUnsubscription
.remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
.exec()
.then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks
}
res.analytics.track('register', {
category: 'acquisition',
type: network,
gaLabel: network,
uuid: savedUser._id,
});
return null;
}
},
};
/**
* @api {put} /api/v3/user/auth/update-username Update username
* @apiDescription Update the username of a local user
* @apiVersion 3.0.0
* @apiName UpdateUsername
* @apiGroup User
*
* @apiParam {string} password Body parameter - The current user password
* @apiParam {string} username Body parameter - The new username
* @apiSuccess {String} data.username The new username
**/
api.updateUsername = {
method: 'PUT',
middlewares: [authWithHeaders()],
url: '/user/auth/update-username',
async handler (req, res) {
let user = res.locals.user;
req.checkBody({
password: {
notEmpty: {errorMessage: res.t('missingPassword')},
},
username: {
notEmpty: { errorMessage: res.t('missingUsername') },
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration'));
let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt);
if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword'));
let count = await User.count({ 'auth.local.lowerCaseUsername': req.body.username.toLowerCase() });
if (count > 0) throw new BadRequest(res.t('usernameTaken'));
// save username
user.auth.local.lowerCaseUsername = req.body.username.toLowerCase();
user.auth.local.username = req.body.username;
await user.save();
res.respond(200, { username: req.body.username });
},
};
/**
* @api {put} /api/v3/user/auth/update-password
* @apiDescription Update the password of a local user
* @apiVersion 3.0.0
* @apiName UpdatePassword
* @apiGroup User
*
* @apiParam {string} password Body parameter - The old password
* @apiParam {string} newPassword Body parameter - The new password
* @apiParam {string} confirmPassword Body parameter - New password confirmation
*
* @apiSuccess {Object} data An empty object
**/
api.updatePassword = {
method: 'PUT',
middlewares: [authWithHeaders()],
url: '/user/auth/update-password',
async handler (req, res) {
let user = res.locals.user;
if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration'));
let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt);
if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword'));
req.checkBody({
password: {
notEmpty: {errorMessage: res.t('missingNewPassword')},
},
newPassword: {
notEmpty: {errorMessage: res.t('missingPassword')},
},
});
if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch'));
user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase
await user.save();
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/user/reset-password Reset password
* @apiDescription Reset the user password
* @apiVersion 3.0.0
* @apiName ResetPassword
* @apiGroup User
*
* @apiParam {string} email Body parameter - The email address of the user
*
* @apiSuccess {string} message The localized success message
**/
api.resetPassword = {
method: 'POST',
middlewares: [],
url: '/user/reset-password',
async handler (req, res) {
req.checkBody({
email: {
notEmpty: {errorMessage: res.t('missingEmail')},
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let email = req.body.email.toLowerCase();
let salt = passwordUtils.makeSalt();
let newPassword = passwordUtils.makeSalt(); // use a salt as the new password too (they'll change it later)
let hashedPassword = passwordUtils.encrypt(newPassword, salt);
let user = await User.findOne({ 'auth.local.email': email }, { 'auth.local': 1 });
if (user) {
user.auth.local.salt = salt;
user.auth.local.hashed_password = hashedPassword; // eslint-disable-line camelcase
sendEmail({
from: 'Habitica <admin@habitica.com>',
to: email,
subject: res.t('passwordResetEmailSubject'),
text: res.t('passwordResetEmailText', { username: user.auth.local.username,
newPassword,
baseUrl: nconf.get('BASE_URL'),
}),
html: res.t('passwordResetEmailHtml', { username: user.auth.local.username,
newPassword,
baseUrl: nconf.get('BASE_URL'),
}),
});
await user.save();
}
res.respond(200, {}, res.t('passwordReset'));
},
};
/**
* @api {put} /api/v3/user/auth/update-email Update email
* @apiDescription Change the user email address
* @apiVersion 3.0.0
* @apiName UpdateEmail
* @apiGroup User
*
* @apiParam {string} Body parameter - newEmail The new email address.
* @apiParam {string} Body parameter - password The user password.
*
* @apiSuccess {string} data.email The updated email address
*/
api.updateEmail = {
method: 'PUT',
middlewares: [authWithHeaders()],
url: '/user/auth/update-email',
async handler (req, res) {
let user = res.locals.user;
if (!user.auth.local.email) throw new BadRequest(res.t('userHasNoLocalRegistration'));
req.checkBody('newEmail', res.t('newEmailRequired')).notEmpty().isEmail();
req.checkBody('password', res.t('missingPassword')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let candidatePassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt);
if (candidatePassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword'));
user.auth.local.email = req.body.newEmail;
await user.save();
return res.respond(200, { email: user.auth.local.email });
},
};
const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
// Internal route
api.getFirebaseToken = {
method: 'POST',
url: '/user/auth/firebase',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
// Expires 24 hours from now (60*60*24*1000) (in milliseconds)
let expires = new Date();
expires.setTime(expires.getTime() + 86400000);
let token = firebaseTokenGenerator.createToken({
uid: user._id,
isHabiticaUser: true,
}, { expires });
res.respond(200, {token, expires});
},
};
/**
* @api {delete} /api/v3/user/auth/social/:network Delete social authentication method
* @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled
* @apiVersion 3.0.0
* @apiName UserDeleteSocial
* @apiGroup User
*
* @apiSuccess {Object} data Empty object
*/
api.deleteSocial = {
method: 'DELETE',
url: '/user/auth/social/:network',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let network = req.params.network;
if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported'));
if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb'));
await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec();
res.respond(200, {});
},
};
module.exports = api;
@@ -0,0 +1,518 @@
import { authWithHeaders, authWithSession } from '../../middlewares/api-v3/auth';
import _ from 'lodash';
import { model as Challenge } from '../../models/challenge';
import {
model as Group,
basicFields as basicGroupFields,
TAVERN_ID,
} from '../../models/group';
import {
model as User,
nameFields,
} from '../../models/user';
import {
NotFound,
NotAuthorized,
} from '../../libs/api-v3/errors';
import * as Tasks from '../../models/task';
import Bluebird from 'bluebird';
import csvStringify from '../../libs/api-v3/csvStringify';
let api = {};
/**
* @api {post} /api/v3/challenges Create a new challenge
* @apiVersion 3.0.0
* @apiName CreateChallenge
* @apiGroup Challenge
*
* @apiSuccess {object} data The newly created challenge
*/
api.createChallenge = {
method: 'POST',
url: '/challenges',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkBody('group', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let groupId = req.body.group;
let prize = req.body.prize;
let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember'));
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
throw new NotAuthorized(res.t('onlyGroupLeaderChal'));
}
if (group._id === TAVERN_ID && prize < 1) {
throw new NotAuthorized(res.t('tavChalsMinPrize'));
}
if (prize > 0) {
let groupBalance = group.balance && group.leader === user._id ? group.balance : 0;
let prizeCost = prize / 4;
if (prizeCost > user.balance + groupBalance) {
throw new NotAuthorized(res.t('cantAfford'));
}
if (groupBalance >= prizeCost) {
// Group pays for all of prize
group.balance -= prizeCost;
} else if (groupBalance > 0) {
// User pays remainder of prize cost after group
let remainder = prizeCost - group.balance;
group.balance = 0;
user.balance -= remainder;
} else {
// User pays for all of prize
user.balance -= prizeCost;
}
}
group.challengeCount += 1;
req.body.leader = user._id;
req.body.official = user.contributor.admin && req.body.official ? true : false;
let challenge = new Challenge(Challenge.sanitize(req.body));
// First validate challenge so we don't save group if it's invalid (only runs sync validators)
let challengeValidationErrors = challenge.validateSync();
if (challengeValidationErrors) throw challengeValidationErrors;
let results = await Bluebird.all([challenge.save({
validateBeforeSave: false, // already validate
}), group.save()]);
let savedChal = results[0];
await savedChal.syncToUser(user); // (it also saves the user)
let response = savedChal.toJSON();
response.leader = { // the leader is the authenticated user
_id: user._id,
profile: {name: user.profile.name},
};
response.group = { // we already have the group data
_id: group._id,
name: group.name,
type: group.type,
privacy: group.privacy,
};
res.respond(201, response);
},
};
/**
* @api {post} /api/v3/challenges/:challengeId/join Joins a challenge
* @apiVersion 3.0.0
* @apiName JoinChallenge
* @apiGroup Challenge
* @apiParam {UUID} challengeId The challenge _id
*
* @apiSuccess {object} data The challenge the user joined
*/
api.joinChallenge = {
method: 'POST',
url: '/challenges/:challengeId/join',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let challenge = await Challenge.findOne({ _id: req.params.challengeId });
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge'));
let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound'));
challenge.memberCount += 1;
// Add all challenge's tasks to user's tasks and save the challenge
let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
let response = results[1].toJSON();
response.group = { // we already have the group data
_id: group._id,
name: group.name,
type: group.type,
privacy: group.privacy,
};
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
res.respond(200, response);
},
};
/**
* @api {post} /api/v3/challenges/:challengeId/leave Leaves a challenge
* @apiVersion 3.0.0
* @apiName LeaveChallenge
* @apiGroup Challenge
* @apiParam {UUID} challengeId The challenge _id
*
* @apiSuccess {object} data An empty object
*/
api.leaveChallenge = {
method: 'POST',
url: '/challenges/:challengeId/leave',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all';
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let challenge = await Challenge.findOne({ _id: req.params.challengeId });
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound'));
challenge.memberCount -= 1;
// Unlink challenge's tasks from user's tasks and save the challenge
await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]);
res.respond(200, {});
},
};
/**
* @api {get} /api/v3/challenges/user Get challenges for a user
* @apiVersion 3.0.0
* @apiName GetUserChallenges
* @apiGroup Challenge
*
* @apiSuccess {Array} data An array of challenges
*/
api.getUserChallenges = {
method: 'GET',
url: '/challenges/user',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenges = await Challenge.find({
$or: [
{_id: {$in: user.challenges}}, // Challenges where the user is participating
{group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member
{leader: user._id}, // Challenges where I'm the leader
],
_id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit
})
.sort('-official -timestamp')
// see below why we're not using populate
// .populate('group', basicGroupFields)
// .populate('leader', nameFields)
.exec();
let resChals = challenges.map(challenge => challenge.toJSON());
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
await Bluebird.all(resChals.map((chal, index) => {
return Bluebird.all([
User.findById(chal.leader).select(nameFields).exec(),
Group.findById(chal.group).select(basicGroupFields).exec(),
]).then(populatedData => {
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null;
});
}));
res.respond(200, resChals);
},
};
/**
* @api {get} /api/v3/challenges/group/group:Id Get challenges for a group
* @apiDescription Get challenges that the user is a member, public challenges and the ones from the user's groups.
* @apiVersion 3.0.0
* @apiName GetGroupChallenges
* @apiGroup Challenge
*
* @apiParam {groupId} groupId The group _id
*
* @apiSuccess {Array} data An array of challenges
*/
api.getGroupChallenges = {
method: 'GET',
url: '/challenges/groups/:groupId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
let challenges = await Challenge.find({group: groupId})
.sort('-official -timestamp')
// .populate('leader', nameFields) // Only populate the leader as the group is implicit
.exec();
let resChals = challenges.map(challenge => challenge.toJSON());
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
await Bluebird.all(resChals.map((chal, index) => {
return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => {
resChals[index].leader = populatedLeader ? populatedLeader.toJSON({minimize: true}) : null;
});
}));
res.respond(200, resChals);
},
};
/**
* @api {get} /api/v3/challenges/:challengeId Get a challenge given its id
* @apiVersion 3.0.0
* @apiName GetChallenge
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The challenge _id
*
* @apiSuccess {object} data The challenge object
*/
api.getChallenge = {
method: 'GET',
url: '/challenges/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findById(challengeId)
// Don't populate the group as we'll fetch it manually later
// .populate('leader', nameFields)
.exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
// Fetching basic group data
let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
let chalRes = challenge.toJSON();
chalRes.group = group.toJSON({minimize: true});
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
let chalLeader = await User.findById(chalRes.leader).select(nameFields).exec();
chalRes.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
res.respond(200, chalRes);
},
};
/**
* @api {get} /api/v3/challenges/:challengeId/export/csv Export a challenge in CSV
* @apiVersion 3.0.0
* @apiName ExportChallengeCsv
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The challenge _id
*
* @apiSuccess {string} challenge A csv file
*/
api.exportChallengeCsv = {
method: 'GET',
url: '/challenges/:challengeId/export/csv',
middlewares: [authWithSession],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findById(challengeId).select('_id group leader tasksOrder').exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
// In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all
// results on the server so the perf difference isn't that big (hopefully)
let [members, tasks] = await Bluebird.all([
User.find({challenges: challengeId})
.select(nameFields)
.sort({_id: 1})
.lean() // so we don't involve mongoose
.exec(),
Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}})
.sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(),
]);
let resArray = members.map(member => [member._id, member.profile.name]);
// We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0]
let lastUserId;
let index = -1;
tasks.forEach(task => {
if (task.userId !== lastUserId) {
lastUserId = task.userId;
index++;
}
resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes);
});
// The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks
let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => {
return result.concat(array);
}, []).sort();
resArray.unshift(['UUID', 'name']);
_.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes'));
res.set({
'Content-Type': 'text/csv',
'Content-disposition': `attachment; filename=${challengeId}.csv`,
});
let csvRes = await csvStringify(resArray);
res.status(200).send(csvRes);
},
};
/**
* @api {put} /api/v3/challenges/:challengeId Update a challenge
* @apiVersion 3.0.0
* @apiName UpdateChallenge
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The challenge _id
*
* @apiSuccess {object} data The updated challenge
*/
api.updateChallenge = {
method: 'PUT',
url: '/challenges/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findById(challengeId).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal'));
_.merge(challenge, Challenge.sanitizeUpdate(req.body));
let savedChal = await challenge.save();
let response = savedChal.toJSON();
response.group = { // we already have the group data
_id: group._id,
name: group.name,
type: group.type,
privacy: group.privacy,
};
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
res.respond(200, response);
},
};
/**
* @api {delete} /api/v3/challenges/:challengeId Delete a challenge
* @apiVersion 3.0.0
* @apiName DeleteChallenge
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The _id for the challenge to delete
*
* @apiSuccess {object} data An empty object
*/
api.deleteChallenge = {
method: 'DELETE',
url: '/challenges/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/challenges/:challengeId/selectWinner/:winnerId Select winner for challenge
* @apiVersion 3.0.0
* @apiName SelectChallengeWinner
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The _id for the challenge to close with a winner
* @apiParam {UUID} winnerId The _id of the winning user
*
* @apiSuccess {object} data An empty object
*/
api.selectChallengeWinner = {
method: 'POST',
url: '/challenges/:challengeId/selectWinner/:winnerId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
let winner = await User.findOne({_id: req.params.winnerId}).exec();
if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId}));
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
res.respond(200, {});
},
};
module.exports = api;
+398
View File
@@ -0,0 +1,398 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import {
model as Group,
TAVERN_ID,
} from '../../models/group';
import { model as User } from '../../models/user';
import {
NotFound,
NotAuthorized,
} from '../../libs/api-v3/errors';
import _ from 'lodash';
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import { sendTxn } from '../../libs/api-v3/email';
import nconf from 'nconf';
import Bluebird from 'bluebird';
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => {
return { email, canSend: true };
});
let api = {};
/**
* @api {get} /api/v3/groups/:groupId/chat Get chat messages from a group
* @apiVersion 3.0.0
* @apiName GetChat
* @apiGroup Chat
*
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Array} data An array of chat messages
*/
api.getChat = {
method: 'GET',
url: '/groups/:groupId/chat',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
res.respond(200, Group.toJSONCleanChat(group, user).chat);
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat Post chat message to a group
* @apiVersion 3.0.0
* @apiName PostCat
* @apiGroup Chat
*
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {message} Body parameter - message The message to post
* @apiParam {previousMsg} previousMsg Query parameter - The previous chat message which will force a return of the full group chat
*
* @apiSuccess data An array of chat messages if a new message was posted after previousMsg, otherwise the posted message
*/
api.postChat = {
method: 'POST',
url: '/groups/:groupId/chat',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
let chatUpdated;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party' && user.flags.chatRevoked) {
throw new NotFound('Your chat privileges have been revoked.');
}
let lastClientMsg = req.query.previousMsg;
chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
group.sendChat(req.body.message, user);
let toSave = [group.save()];
if (group.type === 'party') {
user.party.lastMessageSeen = group.chat[0].id;
toSave.push(user.save());
}
let [savedGroup] = await Bluebird.all(toSave);
if (chatUpdated) {
res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat});
} else {
res.respond(200, {message: savedGroup.chat[0]});
}
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message
* @apiVersion 3.0.0
* @apiName LikeChat
* @apiGroup Chat
*
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {chatId} chatId The chat message _id
*
* @apiSuccess {Object} data The liked chat message
*/
api.likeChat = {
method: 'POST',
url: '/groups/:groupId/chat/:chatId/like',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: req.params.chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage'));
let update = {$set: {}};
if (!message.likes) message.likes = {};
message.likes[user._id] = !message.likes[user._id];
update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id];
await Group.update(
{_id: group._id, 'chat.id': message.id},
update
);
res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned?
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message
* @apiVersion 3.0.0
* @apiName LikeChat
* @apiGroup Chat
*
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {chatId} chatId The chat message id
*
* @apiSuccess {object} data The flagged chat message
*/
api.flagChat = {
method: 'POST',
url: '/groups/:groupId/chat/:chatId/flag',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: req.params.chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage'));
let author = await User.findOne({_id: message.uuid}, {auth: 1});
let update = {$set: {}};
// Log user ids that have flagged the message
if (!message.flags) message.flags = {};
if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported'));
message.flags[user._id] = true;
update.$set[`chat.$.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++;
}
update.$set['chat.$.flagCount'] = message.flagCount;
await Group.update(
{_id: group._id, 'chat.id': message.id},
update
);
let reporterEmailContent;
if (user.auth.local) {
reporterEmailContent = user.auth.local.email;
} else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) {
reporterEmailContent = user.auth.facebook.emails[0].value;
}
let authorEmailContent;
if (author.auth.local) {
authorEmailContent = author.auth.local.email;
} else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) {
authorEmailContent = author.auth.facebook.emails[0].value;
}
let groupUrl;
if (group._id === TAVERN_ID) {
groupUrl = '/#/options/groups/tavern';
} else if (group.type === 'guild') {
groupUrl = `/#/options/groups/guilds/${group._id}`;
} else {
groupUrl = 'party';
}
sendTxn(FLAG_REPORT_EMAILS, '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: reporterEmailContent},
{name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
{name: 'AUTHOR_USERNAME', content: message.user},
{name: 'AUTHOR_UUID', content: message.uuid},
{name: 'AUTHOR_EMAIL', content: authorEmailContent},
{name: 'AUTHOR_MODAL_URL', content: `/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: groupUrl},
]);
res.respond(200, message);
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags
* @apiDescription Admin-only
* @apiVersion 3.0.0
* @apiName ClearFlags
* @apiGroup Chat
*
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {chatId} chatId The chat message id
*
* @apiSuccess {Object} data An empty object
*/
api.clearChatFlags = {
method: 'Post',
url: '/groups/:groupId/chat/:chatId/clearflags',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
let chatId = req.params.chatId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
if (!user.contributor.admin) {
throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount'));
}
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
message.flagCount = 0;
await Group.update(
{_id: group._id, 'chat.id': message.id},
{$set: {'chat.$.flagCount': message.flagCount}}
);
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat/:chatId/seen Seen a group chat message
* @apiVersion 3.0.0
* @apiName SeenChat
* @apiGroup Chat
*
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Object} data An empty object
*/
api.seenChat = {
method: 'POST',
url: '/groups/:groupId/chat/seen',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
// Do not validate group existence, it doesn't really matter and make it works if the group gets deleted
// let group = await Group.getGroup({user, groupId});
// if (!group) throw new NotFound(res.t('groupNotFound'));
let update = {$unset: {}};
update.$unset[`newMessages.${groupId}`] = true;
await User.update({_id: user._id}, update).exec();
res.respond(200, {});
},
};
/**
* @api {delete} /api/v3/groups/:groupId/chat/:chatId Delete chat message from a group
* @apiVersion 3.0.0
* @apiName DeleteChat
* @apiGroup Chat
*
* @apiParam {string} previousMsg Query parameter - The last message fetched by the client so that the whole chat will be returned only if new messages have been posted in the meantime
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {string} chatId The chat message id
*
* @apiSuccess data The updated chat array or an empty object if no message was posted after previousMsg
* @apiSuccess {Object} data An empty object when the previous message was deleted
*/
api.deleteChat = {
method: 'DELETE',
url: '/groups/:groupId/chat/:chatId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
let chatId = req.params.chatId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId, fields: 'chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
if (user._id !== message.uuid && !user.contributor.admin) {
throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat'));
}
let lastClientMsg = req.query.previousMsg;
let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
await Group.update(
{_id: group._id},
{$pull: {chat: {id: chatId}}}
);
if (chatUpdated) {
let chatRes = Group.toJSONCleanChat(group, user).chat;
removeFromArray(chatRes, {id: chatId});
res.respond(200, chatRes);
} else {
res.respond(200, {});
}
},
};
module.exports = api;
@@ -0,0 +1,110 @@
import common from '../../../../common';
import _ from 'lodash';
import { langCodes } from '../../libs/api-v3/i18n';
import Bluebird from 'bluebird';
import fsCallback from 'fs';
import path from 'path';
import logger from '../../libs/api-v3/logger';
// Transform fs methods that accept callbacks in ones that return promises
const fs = {
readFile: Bluebird.promisify(fsCallback.readFile, {context: fsCallback}),
writeFile: Bluebird.promisify(fsCallback.writeFile, {context: fsCallback}),
stat: Bluebird.promisify(fsCallback.stat, {context: fsCallback}),
mkdir: Bluebird.promisify(fsCallback.mkdir, {context: fsCallback}),
};
let api = {};
function walkContent (obj, lang) {
_.each(obj, (item, key, source) => {
if (_.isPlainObject(item) || _.isArray(item)) return walkContent(item, lang);
if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang);
});
}
// After the getContent route is called the first time for a certain language
// the response is saved on disk and subsequentially served directly from there to reduce computation.
// Example: if `cachedContentResponses.en` is true it means that the response is cached
let cachedContentResponses = {};
// Language key set to true while the cache file is being written
let cacheBeingWritten = {};
_.each(langCodes, code => {
cachedContentResponses[code] = false;
cacheBeingWritten[code] = false;
});
const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../build/content_cache/');
async function saveContentToDisk (language, content) {
try {
cacheBeingWritten[language] = true;
await fs.stat(CONTENT_CACHE_PATH); // check if the directory exists, if it doesn't an error is thrown
await fs.writeFile(`${CONTENT_CACHE_PATH}${language}.json`, content, 'utf8');
cacheBeingWritten[language] = false;
cachedContentResponses[language] = true;
} catch (err) {
if (err.code === 'ENOENT' && err.syscall === 'stat') { // the directory doesn't exists, create it and retry
await fs.mkdir(CONTENT_CACHE_PATH);
return saveContentToDisk(language, content);
} else {
cacheBeingWritten[language] = false;
logger.error(err);
return;
}
}
}
/**
* @api {get} /api/v3/content Get all available content objects
* @apiDescription Does not require authentication.
* @apiVersion 3.0.0
* @apiName ContentGet
* @apiGroup Content
*
* @apiParam {string} language Query parameter, the language code used for the items' strings. Defaulting to english
*
* @apiSuccess {Object} data All the content available on Habitica
*/
api.getContent = {
method: 'GET',
url: '/content',
async handler (req, res) {
let language = 'en';
let proposedLang = req.query.language && req.query.language.toString();
if (proposedLang in cachedContentResponses) {
language = proposedLang;
}
let content;
// is the content response for this language cached?
if (cachedContentResponses[language] === true) {
content = await fs.readFile(`${CONTENT_CACHE_PATH}${language}.json`, 'utf8');
} else { // generate the response
content = _.cloneDeep(common.content);
walkContent(content, language);
content = JSON.stringify(content);
}
res.set({
'Content-Type': 'application/json',
});
let jsonResString = `{"success": true, "data": ${content}}`;
res.status(200).send(jsonResString);
// save the file in background unless it's already cached or being written right now
if (cachedContentResponses[language] !== true && cacheBeingWritten[language] !== true) {
saveContentToDisk(language, content);
}
},
};
module.exports = api;
+126
View File
@@ -0,0 +1,126 @@
import csvStringify from '../../libs/api-v3/csvStringify';
import {
authWithHeaders,
authWithSession,
} from '../../middlewares/api-v3/auth';
import { ensureSudo } from '../../middlewares/api-v3/ensureAccessRight';
import { model as Coupon } from '../../models/coupon';
import _ from 'lodash';
import couponCode from 'coupon-code';
let api = {};
/**
* @api {get} /api/v3/coupons Get coupons
* @apiDescription Sudo users only
* @apiVersion 3.0.0
* @apiName GetCoupons
* @apiGroup Coupon
*
* @apiSuccess {string} Coupons in CSV format
*/
api.getCoupons = {
method: 'GET',
url: '/coupons',
middlewares: [authWithSession, ensureSudo],
async handler (req, res) {
let coupons = await Coupon.find().sort('createdAt').lean().exec();
let output = [['code', 'event', 'date', 'user']].concat(_.map(coupons, coupon => {
return [coupon._id, coupon.event, coupon.createdAt, coupon.user];
}));
let csv = await csvStringify(output);
res.set({
'Content-Type': 'text/csv',
'Content-disposition': 'attachment; filename=habitica-coupons.csv',
});
res.status(200).send(csv);
},
};
/**
* @api {post} /api/v3/coupons/generate/:event Generate coupons for an event
* @apiDescription Sudo users only
* @apiVersion 3.0.0
* @apiName GenerateCoupons
* @apiGroup Coupon
*
* @apiParam {string} event The event for which the coupon should be generated
* @apiParam {number} count Query parameter to specify the number of coupon codes to generate
*
* @apiSuccess {array} data Generated coupons
*/
api.generateCoupons = {
method: 'POST',
url: '/coupons/generate/:event',
middlewares: [authWithHeaders(), ensureSudo],
async handler (req, res) {
req.checkParams('event', res.t('eventRequired')).notEmpty();
req.checkQuery('count', res.t('countRequired')).notEmpty().isNumeric();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let coupons = await Coupon.generate(req.params.event, req.query.count);
res.respond(200, coupons);
},
};
/**
* @api {post} /api/v3/user/coupon/:code Enter coupon code
* @apiVersion 3.0.0
* @apiName EnterCouponCode
* @apiGroup Coupon
*
* @apiParam {string} code The coupon code to apply
*
* @apiSuccess {object} data User object
*/
api.enterCouponCode = {
method: 'POST',
url: '/coupons/enter/:code',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
await Coupon.apply(user, req, req.params.code);
res.respond(200, user);
},
};
/**
* @api {post} /api/v3/coupons/validate/:code Validate a coupon code
* @apiVersion 3.0.0
* @apiName ValidateCoupon
* @apiGroup Coupon
*
* @apiSuccess {boolean} data.valid True or false
*/
api.validateCoupon = {
method: 'POST',
url: '/coupons/validate/:code',
middlewares: [authWithHeaders(true)],
async handler (req, res) {
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let valid = false;
let code = couponCode.validate(req.params.code);
if (code) {
let coupon = await Coupon.findOne({_id: code}).exec();
valid = coupon ? true : false;
}
res.respond(200, {valid});
},
};
module.exports = api;
+190
View File
@@ -0,0 +1,190 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode';
import { BadRequest } from '../../libs/api-v3/errors';
import { content } from '../../../../common';
import _ from 'lodash';
let api = {};
/**
* @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user
* @apiDescription Only available in development mode.
* @apiVersion 3.0.0
* @apiName AddTenGems
* @apiGroup Development
*
* @apiSuccess {Object} data An empty Object
*/
api.addTenGems = {
method: 'POST',
url: '/debug/add-ten-gems',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
user.balance += 2.5;
await user.save();
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user
* @apiDescription Only available in development mode.
* @apiVersion 3.0.0
* @apiName AddHourglass
* @apiGroup Development
*
* @apiSuccess {Object} data An empty Object
*/
api.addHourglass = {
method: 'POST',
url: '/debug/add-hourglass',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
user.purchased.plan.consecutive.trinkets += 1;
await user.save();
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/debug/set-cron Sets lastCron for user
* @apiDescription Only available in development mode.
* @apiVersion 3.0.0
* @apiName setCron
* @apiGroup Development
*
* @apiSuccess {Object} data An empty Object
*/
api.setCron = {
method: 'POST',
url: '/debug/set-cron',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let cron = req.body.lastCron;
user.lastCron = cron;
await user.save();
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/debug/make-admin Sets contributor.admin to true
* @apiDescription Only available in development mode.
* @apiVersion 3.0.0
* @apiName setCron
* @apiGroup Development
*
* @apiSuccess {Object} data An empty Object
*/
// TODO: Re-enable after v3 prod testing is done
// api.makeAdmin = {
// method: 'POST',
// url: '/debug/make-admin',
// middlewares: [ensureDevelpmentMode, authWithHeaders()],
// async handler (req, res) {
// let user = res.locals.user;
//
// user.contributor.admin = true;
//
// await user.save();
//
// res.respond(200, {});
// },
// };
/**
* @api {post} /api/v3/debug/modify-inventory Manipulate user's inventory
* @apiDescription Only available in development mode.
* @apiVersion 3.0.0
* @apiName modifyInventory
* @apiGroup Development
*
* @apiSuccess {Object} data An empty Object
*/
api.modifyInventory = {
method: 'POST',
url: '/debug/modify-inventory',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let { gear } = req.body;
if (gear) {
user.items.gear.owned = gear;
}
[
'special',
'pets',
'mounts',
'eggs',
'hatchingPotions',
'food',
'quests',
].forEach((type) => {
if (req.body[type]) {
user.items[type] = req.body[type];
}
});
await user.save();
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/debug/quest-progress Artificially accelerate quest progress
* @apiDescription Only available in development mode.
* @apiVersion 3.0.0
* @apiName questProgress
* @apiGroup Development
*
* @apiSuccess {Object} data An empty Object
*/
api.questProgress = {
method: 'POST',
url: '/debug/quest-progress',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let key = _.get(user, 'party.quest.key');
let quest = content.quests[key];
if (!quest) {
throw new BadRequest('User is not on a valid quest.');
}
if (quest.boss) {
user.party.quest.progress.up += 1000;
}
if (quest.collect) {
let collect = user.party.quest.progress.collect;
_.each(quest.collect, (details, item) => {
collect[item] = collect[item] || 0;
collect[item] += 300;
});
}
user.markModified('party.quest.progress');
await user.save();
res.respond(200, {});
},
};
module.exports = api;
+670
View File
@@ -0,0 +1,670 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import Bluebird from 'bluebird';
import _ from 'lodash';
import {
INVITES_LIMIT,
model as Group,
basicFields as basicGroupFields,
} from '../../models/group';
import {
model as User,
nameFields,
} from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import {
NotFound,
BadRequest,
NotAuthorized,
} from '../../libs/api-v3/errors';
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import * as firebase from '../../libs/api-v3/firebase';
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
import { encrypt } from '../../libs/api-v3/encryption';
import common from '../../../../common';
import sendPushNotification from '../../libs/api-v3/pushNotifications';
let api = {};
/**
* @api {post} /api/v3/groups Create group
* @apiVersion 3.0.0
* @apiName CreateGroup
* @apiGroup Group
*
* @apiSuccess {Object} data The create group
*/
api.createGroup = {
method: 'POST',
url: '/groups',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let group = new Group(Group.sanitize(req.body));
group.leader = user._id;
if (group.type === 'guild') {
if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems'));
group.balance = 1;
user.balance--;
user.guilds.push(group._id);
} else {
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty'));
user.party._id = group._id;
}
let results = await Bluebird.all([user.save(), group.save()]);
let savedGroup = results[1];
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise
let response = savedGroup.toJSON();
// the leader is the authenticated user
response.leader = {
_id: user._id,
profile: {name: user.profile.name},
};
res.respond(201, response); // do not remove chat flags data as we've just created the group
firebase.updateGroupData(savedGroup);
firebase.addUserToGroup(savedGroup._id, user._id);
},
};
/**
* @api {get} /api/v3/groups Get groups for a user
* @apiVersion 3.0.0
* @apiName GetGroups
* @apiGroup Group
*
* @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, guilds, privateGuilds, publicGuilds, tavern
*
* @apiSuccess {Array} data An array of the requested groups
*/
api.getGroups = {
method: 'GET',
url: '/groups',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkQuery('type', res.t('groupTypesRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let types = req.query.type.split(',');
let groupFields = basicGroupFields.concat(' description memberCount balance');
let sort = '-memberCount';
let results = await Group.getGroups({user, types, groupFields, sort});
res.respond(200, results);
},
};
/**
* @api {get} /api/v3/groups/:groupId Get group
* @apiVersion 3.0.0
* @apiName GetGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Object} data The group object
*/
api.getGroup = {
method: 'GET',
url: '/groups/:groupId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false});
if (!group) throw new NotFound(res.t('groupNotFound'));
group = Group.toJSONCleanChat(group, user);
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
let leader = await User.findById(group.leader).select(nameFields).exec();
if (leader) group.leader = leader.toJSON({minimize: true});
res.respond(200, group);
},
};
/**
* @api {put} /api/v3/groups/:groupId Update group
* @apiVersion 3.0.0
* @apiName UpdateGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Object} data The updated group
*/
api.updateGroup = {
method: 'PUT',
url: '/groups/:groupId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
_.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body)));
let savedGroup = await group.save();
let response = Group.toJSONCleanChat(savedGroup, user);
// If the leader changed fetch new data, otherwise use authenticated user
if (response.leader !== user._id) {
response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
} else {
response.leader = {
_id: user._id,
profile: {name: user.profile.name},
};
}
res.respond(200, response);
firebase.updateGroupData(savedGroup);
},
};
/**
* @api {post} /api/v3/groups/:groupId/join Join a group
* @apiVersion 3.0.0
* @apiName JoinGroup
* @apiGroup Group
*
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Object} data The joined group
*/
api.joinGroup = {
method: 'POST',
url: '/groups/:groupId/join',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let inviter;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
// Works even if the user is not yet a member of the group
let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group
if (!group) throw new NotFound(res.t('groupNotFound'));
let isUserInvited = false;
if (group.type === 'party' && group._id === user.invitations.party.id) {
inviter = user.invitations.party.inviter;
user.invitations.party = {}; // Clear invite
user.markModified('invitations.party');
// invite new user to pending quest
if (group.quest.key && !group.quest.active) {
user.party.quest.RSVPNeeded = true;
user.party.quest.key = group.quest.key;
group.quest.members[user._id] = null;
group.markModified('quest.members');
}
// If user was in a different party (when partying solo you can be invited to a new party)
// make him leave that party before doing anything
if (user.party._id) {
let userPreviousParty = await Group.getGroup({user, groupId: user.party._id});
if (userPreviousParty) await userPreviousParty.leave(user);
}
user.party._id = group._id; // Set group as user's party
isUserInvited = true;
} else if (group.type === 'guild') {
let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id });
if (hasInvitation) {
isUserInvited = true;
} else {
isUserInvited = group.privacy === 'private' ? false : true;
}
}
if (isUserInvited && group.type === 'guild') {
if (user.guilds.indexOf(group._id) !== -1) { // if user is already a member (party is checked previously)
throw new NotAuthorized(res.t('userAlreadyInGroup'));
}
user.guilds.push(group._id); // Add group to user's guilds
}
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader
group.memberCount += 1;
let promises = [group.save(), user.save()];
if (group.type === 'party' && inviter) {
promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter
if (group.memberCount > 1) {
promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyUp': {$ne: true}}, {$set: {'achievements.partyUp': true}}, {multi: true}).exec());
}
if (group.memberCount > 3) {
promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyOn': {$ne: true}}, {$set: {'achievements.partyOn': true}}, {multi: true}).exec());
}
}
promises = await Bluebird.all(promises);
let response = Group.toJSONCleanChat(promises[0], user);
let leader = await User.findById(response.leader).select(nameFields).exec();
if (leader) {
response.leader = leader.toJSON({minimize: true});
}
res.respond(200, response);
firebase.addUserToGroup(group._id, user._id);
},
};
/**
* @api {post} /api/v3/groups/:groupId/reject Reject a group invitation
* @apiVersion 3.0.0
* @apiName RejectGroupInvite
* @apiGroup Group
*
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Object} data An empty object
*/
api.rejectGroupInvite = {
method: 'POST',
url: '/groups/:groupId/reject-invite',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let groupId = req.params.groupId;
let isUserInvited = false;
if (groupId === user.invitations.party.id) {
user.invitations.party = {};
user.markModified('invitations.party');
isUserInvited = true;
} else {
let hasInvitation = removeFromArray(user.invitations.guilds, { id: groupId });
if (hasInvitation) {
isUserInvited = true;
}
}
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
await user.save();
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/groups/:groupId/leave Leave a group
* @apiVersion 3.0.0
* @apiName LeaveGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {string="remove-all","keep-all"} keep Query parameter - Whether to keep or not challenges' tasks. Defaults to keep-all
*
* @apiSuccess {Object} data An empty object
*/
api.leaveGroup = {
method: 'POST',
url: '/groups/:groupId/leave',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
// When removing the user from challenges, should we keep the tasks?
req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', requireMembership: true});
if (!group) throw new NotFound(res.t('groupNotFound'));
// During quests, checke wheter user can leave
if (group.type === 'party') {
if (group.quest && group.quest.leader === user._id) {
throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup'));
}
if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) {
throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest'));
}
}
await group.leave(user, req.query.keep);
res.respond(200, {});
},
};
// Send an email to the removed user with an optional message from the leader
function _sendMessageToRemoved (group, removedUser, message) {
if (removedUser.preferences.emailNotifications.kickedGroup !== false) {
sendTxnEmail(removedUser, `kicked-from-${group.type}`, [
{name: 'GROUP_NAME', content: group.name},
{name: 'MESSAGE', content: message},
{name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'},
{name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'},
]);
}
}
/**
* @api {post} /api/v3/groups/:groupId/removeMember/:memberId Remove a member from a group
* @apiVersion 3.0.0
* @apiName RemoveGroupMember
* @apiGroup Group
*
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {UUID} memberId The _id of the member to remove
* @apiParam {string} message Query parameter - The message to send to the removed members
*
* @apiSuccess {Object} data An empty object
*/
api.removeGroupMember = {
method: 'POST',
url: '/groups/:groupId/removeMember/:memberId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat
if (!group) throw new NotFound(res.t('groupNotFound'));
let uuid = req.params.memberId;
if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself'));
let member = await User.findOne({_id: uuid}).exec();
// We're removing the user from a guild or a party? is the user invited only?
let isInGroup;
if (member.party._id === group._id) {
isInGroup = 'party';
} else if (member.guilds.indexOf(group._id) !== -1) {
isInGroup = 'guild';
}
let isInvited;
if (member.invitations.party && member.invitations.party.id === group._id) {
isInvited = 'party';
} else if (_.findIndex(member.invitations.guilds, {id: group._id}) !== -1) {
isInvited = 'guild';
}
if (isInGroup) {
group.memberCount -= 1;
if (group.quest && group.quest.leader === member._id) {
group.quest.key = undefined;
group.quest.leader = undefined;
} else if (group.quest && group.quest.members) {
// remove member from quest
group.quest.members[member._id] = undefined;
group.markModified('quest.members');
}
if (isInGroup === 'guild') {
removeFromArray(member.guilds, group._id);
}
if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
if (member.newMessages[group._id]) {
member.newMessages[group._id] = undefined;
member.markModified('newMessages');
}
if (group.quest && group.quest.active && group.quest.leader === member._id) {
member.items.quests[group.quest.key] += 1;
}
} else if (isInvited) {
if (isInvited === 'guild') {
removeFromArray(member.invitations.guilds, { id: group._id });
}
if (isInvited === 'party') {
user.invitations.party = {};
user.markModified('invitations.party');
}
} else {
throw new NotFound(res.t('groupMemberNotFound'));
}
let message = req.query.message;
if (message) _sendMessageToRemoved(group, member, message);
await Bluebird.all([
member.save(),
group.save(),
]);
res.respond(200, {});
},
};
async function _inviteByUUID (uuid, group, inviter, req, res) {
let userToInvite = await User.findById(uuid).exec();
if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
}
if (group.type === 'guild') {
if (_.contains(userToInvite.guilds, group._id)) {
throw new NotAuthorized(res.t('userAlreadyInGroup'));
}
if (_.find(userToInvite.invitations.guilds, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup'));
}
userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id});
} else if (group.type === 'party') {
if (userToInvite.invitations.party.id) {
throw new NotAuthorized(res.t('userAlreadyPendingInvitation'));
}
if (userToInvite.party._id) {
let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'});
// Allow user to be invited to a new party when they're partying solo
if (userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty'));
}
userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id};
}
let groupLabel = group.type === 'guild' ? 'Guild' : 'Party';
let groupTemplate = group.type === 'guild' ? 'guild' : 'party';
if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) {
let emailVars = [
{name: 'INVITER', content: inviter.profile.name},
];
if (group.type === 'guild') {
emailVars.push(
{name: 'GUILD_NAME', content: group.name},
{name: 'GUILD_URL', content: '/#/options/groups/guilds/public'}
);
} else {
emailVars.push(
{name: 'PARTY_NAME', content: group.name},
{name: 'PARTY_URL', content: '/#/options/groups/party'}
);
}
sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars);
}
sendPushNotification(
userToInvite,
common.i18n.t(group.type === 'guild' ? 'invitedGuild' : 'invitedParty'),
group.name
);
let userInvited = await userToInvite.save();
if (group.type === 'guild') {
return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1];
} else if (group.type === 'party') {
return userInvited.invitations.party;
}
}
async function _inviteByEmail (invite, group, inviter, req, res) {
let userReturnInfo;
if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
let userToContact = await User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
]})
.select({_id: true, 'preferences.emailNotifications': true})
.exec();
if (userToContact) {
userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res);
} else {
userReturnInfo = invite.email;
const groupQueryString = JSON.stringify({
id: group._id,
inviter: inviter._id,
sentAt: Date.now(), // so we can let it expire
});
let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`;
let variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || inviter.profile.name},
];
if (group.type === 'guild') {
variables.push({name: 'GUILD_NAME', content: group.name});
}
// Check for the email address not to be unsubscribed
let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec();
let groupLabel = group.type === 'guild' ? '-guild' : '';
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
}
return userReturnInfo;
}
/**
* @api {post} /api/v3/groups/:groupId/invite Invite users to a group using their UUIDs or email addresses
* @apiVersion 3.0.0
* @apiName InviteToGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiParam {array} emails Body parameter - An array of emails addresses to invite (optional)
* @apiParam {array} uuids Body parameter - An array of uuids to invite (optional)
* @apiParam {string} inviter Body parameter - The inviters' name (optional)
*
* @apiSuccess {array} data The invites
*/
api.inviteToGroup = {
method: 'POST',
url: '/groups/:groupId/invite',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
let uuids = req.body.uuids;
let emails = req.body.emails;
let uuidsIsArray = Array.isArray(uuids);
let emailsIsArray = Array.isArray(emails);
if (!uuids && !emails) {
throw new BadRequest(res.t('canOnlyInviteEmailUuid'));
}
let results = [];
let totalInvites = 0;
if (uuids) {
if (!uuidsIsArray) {
throw new BadRequest(res.t('uuidsMustBeAnArray'));
} else {
totalInvites += uuids.length;
}
}
if (emails) {
if (!emailsIsArray) {
throw new BadRequest(res.t('emailsMustBeAnArray'));
} else {
totalInvites += emails.length;
}
}
if (totalInvites > INVITES_LIMIT) {
throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}));
}
if (uuids) {
let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res));
let uuidResults = await Bluebird.all(uuidInvites);
results.push(...uuidResults);
}
if (emails) {
let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res));
let emailResults = await Bluebird.all(emailInvites);
results.push(...emailResults);
}
res.respond(200, results);
},
};
module.exports = api;
+183
View File
@@ -0,0 +1,183 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import { ensureAdmin } from '../../middlewares/api-v3/ensureAccessRight';
import { model as User } from '../../models/user';
import {
NotFound,
} from '../../libs/api-v3/errors';
import _ from 'lodash';
let api = {};
/**
* @api {get} /api/v3/hall/patrons Get all patrons
* @apiDescription Only the first 50 patrons are returned. More can be accessed passing ?page=n
* @apiVersion 3.0.0
* @apiName GetPatrons
* @apiGroup Hall
*
* @apiParam {Number} page Query Parameter - The result page. Default is 0
*
* @apiSuccess {Array} data An array of patrons
*/
api.getPatrons = {
method: 'GET',
url: '/hall/patrons',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkQuery('page', res.t('pageMustBeNumber')).optional().isNumeric();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let page = req.query.page ? Number(req.query.page) : 0;
const perPage = 50;
let patrons = await User
.find({
'backer.tier': {$gt: 0},
})
.select('contributor backer profile.name')
.sort('-backer.tier')
.skip(page * perPage)
.limit(perPage)
.lean()
.exec();
res.respond(200, patrons);
},
};
/**
* @api {get} /api/v3/hall/heroes Get all Heroes
* @apiVersion 3.0.0
* @apiName GetHeroes
* @apiGroup Hall
*
* @apiSuccess {Array} data An array of heroes
*/
api.getHeroes = {
method: 'GET',
url: '/hall/heroes',
middlewares: [authWithHeaders()],
async handler (req, res) {
let heroes = await User
.find({
'contributor.level': {$gt: 0},
})
.select('contributor backer profile.name')
.sort('-contributor.level')
.lean()
.exec();
res.respond(200, heroes);
},
};
// Note, while the following routes are called getHero / updateHero
// they can be used by admins to get/update any user
const heroAdminFields = 'contributor balance profile.name purchased items auth';
/**
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID
* @apiDescription Must be an admin to make this request.
* @apiVersion 3.0.0
* @apiName GetHero
* @apiGroup Hall
*
* @apiSuccess {Object} data The user object
*/
api.getHero = {
method: 'GET',
url: '/hall/heroes/:heroId',
middlewares: [authWithHeaders(), ensureAdmin],
async handler (req, res) {
let heroId = req.params.heroId;
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let hero = await User
.findById(heroId)
.select(heroAdminFields)
.exec();
if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId}));
let heroRes = hero.toJSON({minimize: true});
// supply to the possible absence of hero.contributor
// if we didn't pass minimize: true it would have returned all fields as empty
if (!heroRes.contributor) heroRes.contributor = {};
res.respond(200, heroRes);
},
};
// e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff
const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
/**
* @api {put} /api/v3/hall/heroes/:heroId Update any user ("hero")
* @apiDescription Must be an admin to make this request.
* @apiVersion 3.0.0
* @apiName UpdateHero
* @apiGroup Hall
*
* @apiSuccess {Object} data The updated user object
*/
api.updateHero = {
method: 'PUT',
url: '/hall/heroes/:heroId',
middlewares: [authWithHeaders(), ensureAdmin],
async handler (req, res) {
let heroId = req.params.heroId;
let updateData = req.body;
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let hero = await User.findById(heroId).exec();
if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId}));
if (updateData.balance) hero.balance = updateData.balance;
// give them gems if they got an higher level
let newTier = updateData.contributor && updateData.contributor.level; // tier = level in this context
let oldTier = hero.contributor && hero.contributor.level || 0;
if (newTier > oldTier) {
hero.flags.contributor = true;
let tierDiff = newTier - oldTier; // can be 2+ tier increases at once
while (tierDiff) {
hero.balance += gemsPerTier[newTier] / 4; // balance is in $
tierDiff--;
newTier--; // give them gems for the next tier down if they weren't aready that tier
}
}
if (updateData.contributor) _.assign(hero.contributor, updateData.contributor);
if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads;
// give them the Dragon Hydra pet if they're above level 6
if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5;
if (updateData.itemPath && updateData.itemVal &&
updateData.itemPath.indexOf('items.') === 0 &&
User.schema.paths[updateData.itemPath]) {
_.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
}
if (updateData.auth && _.isBoolean(updateData.auth.blocked)) hero.auth.blocked = updateData.auth.blocked;
let savedHero = await hero.save();
let heroJSON = savedHero.toJSON();
let responseHero = {_id: heroJSON._id}; // only respond with important fields
heroAdminFields.split(' ').forEach(field => {
_.set(responseHero, field, _.get(heroJSON, field));
});
res.respond(200, responseHero);
},
};
module.exports = api;
+4
View File
@@ -0,0 +1,4 @@
// NOTE: this file is only used because the mobile apps expect IAP routes
// to be found at /api/v3/iap instead of /iap.
module.exports = require('../top-level/payments/iap');
@@ -0,0 +1,364 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import {
model as User,
publicFields as memberFields,
nameFields,
} from '../../models/user';
import { model as Group } from '../../models/group';
import { model as Challenge } from '../../models/challenge';
import {
NotFound,
NotAuthorized,
} from '../../libs/api-v3/errors';
import * as Tasks from '../../models/task';
import {
getUserInfo,
sendTxn as sendTxnEmail,
} from '../../libs/api-v3/email';
import Bluebird from 'bluebird';
import sendPushNotification from '../../libs/api-v3/pushNotifications';
let api = {};
/**
* @api {get} /api/v3/members/:memberId Get a member profile
* @apiVersion 3.0.0
* @apiName GetMember
* @apiGroup Member
*
* @apiParam {UUID} memberId The member's id
*
* @apiSuccess {object} data The member object
*/
api.getMember = {
method: 'GET',
url: '/members/:memberId',
middlewares: [],
async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let memberId = req.params.memberId;
let member = await User
.findById(memberId)
.select(memberFields)
.exec();
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
// manually call toJSON with minimize: true so empty paths aren't returned
res.respond(200, member.toJSON({minimize: true}));
},
};
// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge
// type is `invites` or `members`
function _getMembersForItem (type) {
if (['group-members', 'group-invites', 'challenge-members'].indexOf(type) === -1) {
throw new Error('Type must be one of "group-members", "group-invites", "challenge-members"');
}
return async function handleGetMembersForItem (req, res) {
if (type === 'challenge-members') {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
} else {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
}
req.checkQuery('lastId').optional().notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let groupId = req.params.groupId;
let challengeId = req.params.challengeId;
let lastId = req.query.lastId;
let user = res.locals.user;
let challenge;
let group;
if (type === 'challenge-members') {
challenge = await Challenge.findById(challengeId).select('_id type leader group').exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
// optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge
// for example if you've been booted from it, are the leader or a site admin
group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
} else {
group = await Group.getGroup({user, groupId, fields: '_id type'});
if (!group) throw new NotFound(res.t('groupNotFound'));
}
let query = {};
let fields = nameFields;
if (type === 'challenge-members') {
query.challenges = challenge._id;
} else if (type === 'group-members') {
if (group.type === 'guild') {
query.guilds = group._id;
} else {
query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party'
if (req.query.includeAllPublicFields === 'true') {
fields = memberFields;
}
}
} else if (type === 'group-invites') {
if (group.type === 'guild') { // eslint-disable-line no-lonely-if
query['invitations.guilds.id'] = group._id;
} else {
query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party'
}
}
if (lastId) query._id = {$gt: lastId};
let members = await User
.find(query)
.sort({_id: 1})
.limit(30)
.select(fields)
.exec();
// manually call toJSON with minimize: true so empty paths aren't returned
res.respond(200, members.map(member => member.toJSON({minimize: true})));
};
}
/**
* @api {get} /api/v3/groups/:groupId/members Get members for a group
* @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
* @apiVersion 3.0.0
* @apiName GetMembersForGroup
* @apiGroup Member
*
* @apiParam {UUID} groupId The group id
* @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
* @apiParam {boolean} includeAllPublicFields Query parameter available only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member)
*
* @apiSuccess {array} data An array of members, sorted by _id
*/
api.getMembersForGroup = {
method: 'GET',
url: '/groups/:groupId/members',
middlewares: [authWithHeaders()],
handler: _getMembersForItem('group-members'),
};
/**
* @api {get} /api/v3/groups/:groupId/invites Get invites for a group
* @apiDescription With a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
* @apiVersion 3.0.0
* @apiName GetInvitesForGroup
* @apiGroup Member
*
* @apiParam {UUID} groupId The group id
* @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results
*
* @apiSuccess {array} data An array of invites, sorted by _id
*/
api.getInvitesForGroup = {
method: 'GET',
url: '/groups/:groupId/invites',
middlewares: [authWithHeaders()],
handler: _getMembersForItem('group-invites'),
};
/**
* @api {get} /api/v3/challenges/:challengeId/members Get members for a challenge
* @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
* @apiVersion 3.0.0
* @apiName GetMembersForChallenge
* @apiGroup Member
*
* @apiParam {UUID} challengeId The challenge id
* @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
*
* @apiSuccess {array} data An array of members, sorted by _id
*/
api.getMembersForChallenge = {
method: 'GET',
url: '/challenges/:challengeId/members',
middlewares: [authWithHeaders()],
handler: _getMembersForItem('challenge-members'),
};
/**
* @api {get} /api/v3/challenges/:challengeId/members/:memberId Get a challenge member progress
* @apiVersion 3.0.0
* @apiName GetChallenge
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The challenge _id
* @apiParam {UUID} member The member _id
*
* @apiSuccess {object} data Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member
*/
api.getChallengeMemberProgress = {
method: 'GET',
url: '/challenges/:challengeId/members/:memberId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let memberId = req.params.memberId;
let member = await User.findById(memberId).select(`${nameFields} challenges`).exec();
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
let challenge = await Challenge.findById(challengeId).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
// optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge
// for example if you've been booted from it, are the leader or a site admin
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound'));
let chalTasks = await Tasks.Task.find({
userId: memberId,
'challenge.id': challengeId,
})
.select('-tags') // We don't want to return the tags publicly TODO same for other data?
.exec();
// manually call toJSON with minimize: true so empty paths aren't returned
let response = member.toJSON({minimize: true});
delete response.challenges;
response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true}));
res.respond(200, response);
},
};
/**
* @api {posts} /members/send-private-message Send a private message to a member
* @apiVersion 3.0.0
* @apiName SendPrivateMessage
* @apiGroup Members
*
* @apiParam {String} message Body parameter - The message
* @apiParam {UUID} toUserId Body parameter - The user to contact
*
* @apiSuccess {Object} data An empty Object
*/
api.sendPrivateMessage = {
method: 'POST',
url: '/members/send-private-message',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkBody('message', res.t('messageRequired')).notEmpty();
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let sender = res.locals.user;
let message = req.body.message;
let receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
let userBlockedSender = receiver.inbox.blocks.indexOf(sender._id) !== -1;
let userIsBlockBySender = sender.inbox.blocks.indexOf(receiver._id) !== -1;
let userOptedOutOfMessaging = receiver.inbox.optOut;
if (userBlockedSender || userIsBlockBySender || userOptedOutOfMessaging) {
throw new NotAuthorized(res.t('notAuthorizedToSendMessageToThisUser'));
}
await sender.sendMessage(receiver, message);
if (receiver.preferences.emailNotifications.newPM !== false) {
sendTxnEmail(receiver, 'new-pm', [
{name: 'SENDER', content: getUserInfo(sender, ['name']).name},
{name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'},
]);
}
res.respond(200, {});
},
};
/**
* @api {posts} /members/transfer-gems Send a gem gift to a member
* @apiVersion 3.0.0
* @apiName TransferGems
* @apiGroup Members
*
* @apiParam {String} message Body parameter The message
* @apiParam {UUID} toUserId Body parameter The toUser _id
* @apiParam {Integer} gemAmount Body parameter The number of gems to send
*
* @apiSuccess {Object} data An empty Object
*/
api.transferGems = {
method: 'POST',
url: '/members/transfer-gems',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
req.checkBody('gemAmount', res.t('gemAmountRequired')).notEmpty().isInt();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let sender = res.locals.user;
let receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
if (receiver._id === sender._id) {
throw new NotAuthorized(res.t('cannotSendGemsToYourself'));
}
let gemAmount = req.body.gemAmount;
let amount = gemAmount / 4;
if (amount <= 0 || sender.balance < amount) {
throw new NotAuthorized(res.t('badAmountOfGemsToSend'));
}
receiver.balance += amount;
sender.balance -= amount;
let promises = [receiver.save(), sender.save()];
await Bluebird.all(promises);
let message = res.t('privateMessageGiftIntro', {
receiverName: receiver.profile.name,
senderName: sender.profile.name,
});
message += res.t('privateMessageGiftGemsMessage', {gemAmount});
if (req.body.message) {
message += req.body.message;
}
await sender.sendMessage(receiver, message);
let byUsername = getUserInfo(sender, ['name']).name;
if (receiver.preferences.emailNotifications.giftedGems !== false) {
sendTxnEmail(receiver, 'gifted-gems', [
{name: 'GIFTER', content: byUsername},
{name: 'X_GEMS_GIFTED', content: gemAmount},
]);
}
sendPushNotification(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername }));
res.respond(200, {});
},
};
module.exports = api;
@@ -0,0 +1,40 @@
import mongoose from 'mongoose';
let api = {};
let tasksModels = ['habit', 'daily', 'todo', 'reward'];
let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels);
/**
* @api {get} /api/v3/models/:model/paths Get all paths for the specified model
* @apiDescription Doesn't require authentication
* @apiVersion 3.0.0
* @apiName GetUserModelPaths
* @apiGroup Meta
*
* @apiParam {string="user","group","challenge","tag","habit","daily","todo","reward"} model The name of the model
*
* @apiSuccess {object} data A key-value object made of fieldPath: fieldType (like {'field.nested': Boolean})
*/
api.getModelPaths = {
method: 'GET',
url: '/models/:model/paths',
async handler (req, res) {
req.checkParams('model', res.t('modelNotFound')).notEmpty().isIn(allModels);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let model = req.params.model;
// tasks models are lowercase, the others have the first letter uppercase (User, Group)
if (tasksModels.indexOf(model) === -1) {
model = model.charAt(0).toUpperCase() + model.slice(1);
}
model = mongoose.model(model);
res.respond(200, model.getModelPaths());
},
};
module.exports = api;
+451
View File
@@ -0,0 +1,451 @@
import _ from 'lodash';
import Bluebird from 'bluebird';
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import analytics from '../../libs/api-v3/analyticsService';
import {
model as Group,
} from '../../models/group';
import { model as User } from '../../models/user';
import {
NotFound,
NotAuthorized,
BadRequest,
} from '../../libs/api-v3/errors';
import {
getUserInfo,
sendTxn as sendTxnEmail,
} from '../../libs/api-v3/email';
import common from '../../../../common';
import sendPushNotification from '../../libs/api-v3/pushNotifications';
const questScrolls = common.content.quests;
function canStartQuestAutomatically (group) {
// If all members are either true (accepted) or false (rejected) return true
// If any member is null/undefined (undecided) return false
return _.every(group.quest.members, _.isBoolean);
}
let api = {};
/**
* @api {post} /api/v3/groups/:groupId/quests/invite Invite users to a quest
* @apiVersion 3.0.0
* @apiName InviteToQuest
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
* @apiParam {string} questKey
*
* @apiSuccess {Object} data Quest object
*/
api.inviteToQuest = {
method: 'POST',
url: '/groups/:groupId/quests/invite/:questKey',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let questKey = req.params.questKey;
let quest = questScrolls[questKey];
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey }));
if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned'));
if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl }));
if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway'));
let members = await User.find({
'party._id': group._id,
_id: {$ne: user._id},
}).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices')
.exec();
group.markModified('quest');
group.quest.key = questKey;
group.quest.leader = user._id;
group.quest.members = {};
group.quest.members[user._id] = true;
user.party.quest.RSVPNeeded = false;
user.party.quest.key = questKey;
await User.update({
'party._id': group._id,
_id: {$ne: user._id},
}, {
$set: {
'party.quest.RSVPNeeded': true,
'party.quest.key': questKey,
},
}, {multi: true}).exec();
_.each(members, (member) => {
group.quest.members[member._id] = null;
});
if (canStartQuestAutomatically(group)) {
await group.startQuest(user);
}
let [savedGroup] = await Bluebird.all([
group.save(),
user.save(),
]);
res.respond(200, savedGroup.quest);
// send out invites
let inviterVars = getUserInfo(user, ['name', 'email']);
let membersToEmail = members.filter(member => {
// send push notifications while filtering members before sending emails
sendPushNotification(
member,
common.i18n.t('questInvitationTitle'),
common.i18n.t('questInvitationInfo', { quest: quest.text() })
);
return member.preferences.emailNotifications.invitedQuest !== false;
});
sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [
{name: 'QUEST_NAME', content: quest.text()},
{name: 'INVITER', content: inviterVars.name},
{name: 'PARTY_URL', content: '/#/options/groups/party'},
]);
// track that the inviting user has accepted the quest
analytics.track('quest', {
category: 'behavior',
owner: true,
response: 'accept',
gaLabel: 'accept',
questName: questKey,
uuid: user._id,
});
},
};
/**
* @api {post} /api/v3/groups/:groupId/quests/accept Accept a pending quest
* @apiVersion 3.0.0
* @apiName AcceptQuest
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} data Quest Object
*/
api.acceptQuest = {
method: 'POST',
url: '/groups/:groupId/quests/accept',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound'));
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
group.markModified('quest');
group.quest.members[user._id] = true;
user.party.quest.RSVPNeeded = false;
if (canStartQuestAutomatically(group)) {
await group.startQuest(user);
}
let [savedGroup] = await Bluebird.all([
group.save(),
user.save(),
]);
res.respond(200, savedGroup.quest);
// track that a user has accepted the quest
analytics.track('quest', {
category: 'behavior',
owner: false,
response: 'accept',
gaLabel: 'accept',
questName: group.quest.key,
uuid: user._id,
});
},
};
/**
* @api {post} /api/v3/groups/:groupId/quests/reject Reject a quest
* @apiVersion 3.0.0
* @apiName RejectQuest
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} data Quest Object
*/
api.rejectQuest = {
method: 'POST',
url: '/groups/:groupId/quests/reject',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected'));
group.quest.members[user._id] = false;
group.markModified('quest.members');
user.party.quest = Group.cleanQuestProgress();
user.markModified('party.quest');
if (canStartQuestAutomatically(group)) {
await group.startQuest(user);
}
let [savedGroup] = await Bluebird.all([
group.save(),
user.save(),
]);
res.respond(200, savedGroup.quest);
analytics.track('quest', {
category: 'behavior',
owner: false,
response: 'reject',
gaLabel: 'reject',
questName: group.quest.key,
uuid: user._id,
});
},
};
/**
* @api {post} /api/v3/groups/:groupId/quests/force-start Force-start a pending quest
* @apiVersion 3.0.0
* @apiName ForceQuestStart
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} data Quest Object
*/
api.forceStart = {
method: 'POST',
url: '/groups/:groupId/quests/force-start',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
if (!group.quest.key) throw new NotFound(res.t('questNotPending'));
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest'));
group.markModified('quest');
await group.startQuest(user);
let [savedGroup] = await Bluebird.all([
group.save(),
user.save(),
]);
res.respond(200, savedGroup.quest);
analytics.track('quest', {
category: 'behavior',
owner: user._id === group.quest.leader,
response: 'force-start',
gaLabel: 'force-start',
questName: group.quest.key,
uuid: user._id,
});
},
};
/**
* @api {post} /api/v3/groups/:groupId/quests/cancel Cancels a quest
* @apiVersion 3.0.0
* @apiName CancelQuest
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} data Quest Object
*/
api.cancelQuest = {
method: 'POST',
url: '/groups/:groupId/quests/cancel',
middlewares: [authWithHeaders()],
async handler (req, res) {
// 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.
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId, fields: 'type leader quest'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest'));
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
group.quest = Group.cleanGroupQuest();
group.markModified('quest');
let [savedGroup] = await Bluebird.all([
group.save(),
User.update(
{'party._id': groupId},
{$set: {'party.quest': Group.cleanQuestProgress()}},
{multi: true}
),
]);
res.respond(200, savedGroup.quest);
},
};
/**
* @api {post} /api/v3/groups/:groupId/quests/abort Abort the current quest
* @apiVersion 3.0.0
* @apiName AbortQuest
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} data Quest Object
*/
api.abortQuest = {
method: 'POST',
url: '/groups/:groupId/quests/abort',
middlewares: [authWithHeaders()],
async handler (req, res) {
// Abort a quest AFTER it has begun (see questCancel for BEFORE)
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId, fields: 'type quest leader'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort'));
if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
let memberUpdates = User.update({
'party._id': groupId,
}, {
$set: {'party.quest': Group.cleanQuestProgress()},
}, {multi: true}).exec();
let questLeaderUpdate = User.update({
_id: group.quest.leader,
}, {
$inc: {
[`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader
},
}).exec();
group.quest = Group.cleanGroupQuest();
group.markModified('quest');
let [groupSaved] = await Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]);
res.respond(200, groupSaved.quest);
},
};
/**
* @api {post} /api/v3/groups/:groupId/quests/leave Leaves the active quest
* @apiVersion 3.0.0
* @apiName LeaveQuest
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} data Quest Object
*/
api.leaveQuest = {
method: 'POST',
url: '/groups/:groupId/quests/leave',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId, fields: 'type quest'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave'));
if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest'));
if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest'));
group.quest.members[user._id] = false;
group.markModified('quest.members');
user.party.quest = Group.cleanQuestProgress();
user.markModified('party.quest');
let [savedGroup] = await Bluebird.all([
group.save(),
user.save(),
]);
res.respond(200, savedGroup.quest);
},
};
module.exports = api;
@@ -0,0 +1,21 @@
let api = {};
/**
* @api {get} /api/v3/status Get Habitica's API status
* @apiVersion 3.0.0
* @apiName GetStatus
* @apiGroup Status
*
* @apiSuccess {status} data.status 'up' if everything is ok
*/
api.getStatus = {
method: 'GET',
url: '/status',
async handler (req, res) {
res.respond(200, {
status: 'up',
});
},
};
module.exports = api;
+190
View File
@@ -0,0 +1,190 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import { model as Tag } from '../../models/tag';
import * as Tasks from '../../models/task';
import {
NotFound,
} from '../../libs/api-v3/errors';
import _ from 'lodash';
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
let api = {};
/**
* @api {post} /api/v3/tags Create a new tag
* @apiVersion 3.0.0
* @apiName CreateTag
* @apiGroup Tag
*
* @apiSuccess {Object} data The newly created tag
*/
api.createTag = {
method: 'POST',
url: '/tags',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
user.tags.push(Tag.sanitize(req.body));
let savedUser = await user.save();
let l = savedUser.tags.length;
let tag = savedUser.tags[l - 1];
res.respond(201, tag);
},
};
/**
* @api {get} /api/v3/tag Get a user's tags
* @apiVersion 3.0.0
* @apiName GetTags
* @apiGroup Tag
*
* @apiSuccess {Array} data An array of tags
*/
api.getTags = {
method: 'GET',
url: '/tags',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
res.respond(200, user.tags);
},
};
/**
* @api {get} /api/v3/tags/:tagId Get a tag given its id
* @apiVersion 3.0.0
* @apiName GetTag
* @apiGroup Tag
*
* @apiParam {UUID} tagId The tag _id
*
* @apiSuccess {object} data The tag object
*/
api.getTag = {
method: 'GET',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let tag = _.find(user.tags, {id: req.params.tagId});
if (!tag) throw new NotFound(res.t('tagNotFound'));
res.respond(200, tag);
},
};
/**
* @api {put} /api/v3/tag/:tagId Update a tag
* @apiVersion 3.0.0
* @apiName UpdateTag
* @apiGroup Tag
*
* @apiParam {UUID} tagId The tag _id
*
* @apiSuccess {object} data The updated tag
*/
api.updateTag = {
method: 'PUT',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let tagId = req.params.tagId;
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let tag = _.find(user.tags, {id: tagId});
if (!tag) throw new NotFound(res.t('tagNotFound'));
_.merge(tag, Tag.sanitize(req.body));
let savedUser = await user.save();
res.respond(200, _.find(savedUser.tags, {id: tagId}));
},
};
/**
* @api {post} /api/v3/reorder-tags Reorder a tag
* @apiVersion 3.0.0
* @apiName ReorderTags
* @apiGroup Tag
*
* @apiParam {tagId} UUID Id of the tag to move
* @apiParam {to} number Position the tag is moving to
*
* @apiSuccess {object} data An empty object
*/
api.reorderTags = {
method: 'POST',
url: '/reorder-tags',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkBody('to', res.t('toRequired')).notEmpty();
req.checkBody('tagId', res.t('tagIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let tagIndex = _.findIndex(user.tags, function findTag (tag) {
return tag.id === req.body.tagId;
});
if (tagIndex === -1) throw new NotFound(res.t('tagNotFound'));
user.tags.splice(req.body.to, 0, user.tags.splice(tagIndex, 1)[0]);
await user.save();
res.respond(200, {});
},
};
/**
* @api {delete} /api/v3/tag/:tagId Delete a user tag given its id
* @apiVersion 3.0.0
* @apiName DeleteTag
* @apiGroup Tag
*
* @apiParam {UUID} tagId The tag _id
*
* @apiSuccess {object} data An empty object
*/
api.deleteTag = {
method: 'DELETE',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let tag = removeFromArray(user.tags, { id: req.params.tagId });
if (!tag) throw new NotFound(res.t('tagNotFound'));
// Remove from all the tasks TODO test
await Tasks.Task.update({
userId: user._id,
}, {
$pull: {
tags: tag.id,
},
}, {multi: true}).exec();
await user.save();
res.respond(200, {});
},
};
module.exports = api;
+971
View File
@@ -0,0 +1,971 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import { sendTaskWebhook } from '../../libs/api-v3/webhook';
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import * as Tasks from '../../models/task';
import { model as Challenge } from '../../models/challenge';
import { model as Group } from '../../models/group';
import {
NotFound,
NotAuthorized,
BadRequest,
} from '../../libs/api-v3/errors';
import common from '../../../../common';
import Bluebird from 'bluebird';
import _ from 'lodash';
import logger from '../../libs/api-v3/logger';
let api = {};
// challenge must be passed only when a challenge task is being created
async function _createTasks (req, res, user, challenge) {
let toSave = Array.isArray(req.body) ? req.body : [req.body];
toSave = toSave.map(taskData => {
// Validate that task.type is valid
if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType'));
let taskType = taskData.type;
let newTask = new Tasks[taskType](Tasks.Task.sanitize(taskData));
if (challenge) {
newTask.challenge.id = challenge.id;
} else {
newTask.userId = user._id;
}
// Validate that the task is valid and throw if it isn't
// otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality
let validationErrors = newTask.validateSync();
if (validationErrors) throw validationErrors;
// Otherwise update the user/challenge
(challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id);
return newTask;
}).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again
validateBeforeSave: false,
}));
toSave.unshift((challenge || user).save());
let tasks = await Bluebird.all(toSave);
tasks.splice(0, 1); // Remove user or challenge
return tasks;
}
/**
* @api {post} /api/v3/tasks/user Create a new task belonging to the user
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiVersion 3.0.0
* @apiName CreateUserTasks
* @apiGroup Task
*
* @apiSuccess data An object if a single task was created, otherwise an array of tasks
*/
api.createUserTasks = {
method: 'POST',
url: '/tasks/user',
middlewares: [authWithHeaders()],
async handler (req, res) {
let tasks = await _createTasks(req, res, res.locals.user);
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
},
};
/**
* @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to a challenge
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiVersion 3.0.0
* @apiName CreateChallengeTasks
* @apiGroup Task
*
* @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to
*
* @apiSuccess data An object if a single task was created, otherwise an array of tasks
*/
api.createChallengeTasks = {
method: 'POST',
url: '/tasks/challenge/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let reqValidationErrors = req.validationErrors();
if (reqValidationErrors) throw reqValidationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findOne({_id: challengeId}).exec();
// If the challenge does not exist, or if it exists but user is not the leader -> throw error
if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
let tasks = await _createTasks(req, res, user, challenge);
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
// If adding tasks to a challenge -> sync users
if (challenge) challenge.addTasks(tasks);
return null;
},
};
// challenge must be passed only when a challenge task is being created
async function _getTasks (req, res, user, challenge) {
let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id};
let type = req.query.type;
if (type) {
if (type === 'todos') {
query.completed = false; // Exclude completed todos
query.type = 'todo';
} else if (type === 'completedTodos') {
query = Tasks.Task.find({
userId: user._id,
type: 'todo',
completed: true,
}).limit(30).sort({ // TODO add ability to pick more than 30 completed todos
dateCompleted: -1,
});
} else {
query.type = type.slice(0, -1); // removing the final "s"
}
} else {
query.$or = [ // Exclude completed todos
{type: 'todo', completed: false},
{type: {$in: ['habit', 'daily', 'reward']}},
];
}
let tasks = await Tasks.Task.find(query).exec();
// Order tasks based on tasksOrder
if (type && type !== 'completedTodos') {
let order = (challenge || user).tasksOrder[type];
let orderedTasks = new Array(tasks.length);
let unorderedTasks = []; // what we want to add later
tasks.forEach((task, index) => {
let taskId = task._id;
let i = order[index] === taskId ? index : order.indexOf(taskId);
if (i === -1) {
unorderedTasks.push(task);
} else {
orderedTasks[i] = task;
}
});
// Remove empty values from the array and add any unordered task
orderedTasks = _.compact(orderedTasks).concat(unorderedTasks);
res.respond(200, orderedTasks);
} else {
res.respond(200, tasks);
}
}
/**
* @api {get} /api/v3/tasks/user Get a user's tasks
* @apiVersion 3.0.0
* @apiName GetUserTasks
* @apiGroup Task
*
* @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that must be requested separately.
*
* @apiSuccess {Array} data An array of tasks
*/
api.getUserTasks = {
method: 'GET',
url: '/tasks/user',
middlewares: [authWithHeaders()],
async handler (req, res) {
let types = Tasks.tasksTypes.map(type => `${type}s`);
types.push('completedTodos');
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
return await _getTasks(req, res, res.locals.user);
},
};
/**
* @api {get} /api/v3/tasks/challenge/:challengeId Get a challenge's tasks
* @apiVersion 3.0.0
* @apiName GetChallengeTasks
* @apiGroup Task
*
* @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks
* @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks
*
* @apiSuccess {Array} data An array of tasks
*/
api.getChallengeTasks = {
method: 'GET',
url: '/tasks/challenge/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let types = Tasks.tasksTypes.map(type => `${type}s`);
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findOne({_id: challengeId}).select('group leader tasksOrder').exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
return await _getTasks(req, res, res.locals.user, challenge);
},
};
/**
* @api {get} /api/v3/task/:taskId Get a task
* @apiVersion 3.0.0
* @apiName GetTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data The task object
*/
api.getTask = {
method: 'GET',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
let challenge = await Challenge.find({_id: task.challenge.id}).select('leader').exec();
if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens
throw new NotFound(res.t('taskNotFound'));
}
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
res.respond(200, task);
},
};
/**
* @api {put} /api/v3/task/:taskId Update a task
* @apiVersion 3.0.0
* @apiName UpdateTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data The updated task
*/
api.updateTask = {
method: 'PUT',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req);
// Sanitize differently user tasks linked to a challenge
let sanitizedObj;
if (!challenge && task.userId && task.challenge && task.challenge.id) {
sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj);
} else {
sanitizedObj = Tasks.Task.sanitize(updatedTaskObj);
}
_.assign(task, sanitizedObj);
// console.log(task.modifiedPaths(), task.toObject().repeat === tep)
// repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject()
// see https://github.com/Automattic/mongoose/issues/2749
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
function _generateWebhookTaskData (task, direction, delta, stats, user) {
let extendedStats = _.extend(stats, {
toNextLevel: common.tnl(user.stats.lvl),
maxHealth: common.maxHealth,
maxMP: common.statsComputed(user).maxMP,
});
let userData = {
_id: user._id,
_tmp: user._tmp,
stats: extendedStats,
};
let taskData = {
details: task,
direction,
delta,
};
return {
task: taskData,
user: userData,
};
}
/**
* @api {put} /api/v3/tasks/:taskId/score/:direction Score a task
* @apiVersion 3.0.0
* @apiName ScoreTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {string="up","down"} direction The direction for scoring the task
*
* @apiSuccess {object} data._tmp If an item was dropped it'll be returned in te _tmp object
* @apiSuccess {number} data.delta
* @apiSuccess {object} data The user stats
*/
api.scoreTask = {
method: 'POST',
url: '/tasks/:taskId/score/:direction',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let direction = req.params.direction;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
let wasCompleted = task.completed;
let [delta] = common.ops.scoreTask({task, user, direction}, req);
// Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
if (direction === 'up') user.fns.randomDrop({task, delta}, req);
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
// TODO move to common code?
if (task.type === 'todo') {
if (!wasCompleted && task.completed) {
removeFromArray(user.tasksOrder.todos, task._id);
} else if (wasCompleted && !task.completed) {
let hasTask = removeFromArray(user.tasksOrder.todos, task._id);
if (!hasTask) {
user.tasksOrder.todos.push(task._id);
} // If for some reason it hadn't been removed previously don't do anything
}
}
let results = await Bluebird.all([
user.save(),
task.save(),
]);
let savedUser = results[0];
let userStats = savedUser.stats.toJSON();
let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats);
res.respond(200, resJsonData);
sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user));
if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') {
// Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled
try {
let chalTask = await Tasks.Task.findOne({
_id: task.challenge.taskId,
}).exec();
await chalTask.scoreChallengeTask(delta);
} catch (e) {
logger.error(e);
}
}
return null;
},
};
/**
* @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position
* @apiDescription Note: completed To-Dos are not sortable, do not appear in user.tasksOrder.todos, and are ordered by date of completion.
* @apiVersion 3.0.0
* @apiName MoveTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {Number} position Query parameter - Where to move the task (-1 means push to bottom). First position is 0
*
* @apiSuccess {array} data The new tasks order (user.tasksOrder.{task.type}s)
*/
api.moveTask = {
method: 'POST',
url: '/tasks/:taskId/move/to/:position',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let to = Number(req.params.position);
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo'));
let order = user.tasksOrder[`${task.type}s`];
let currentIndex = order.indexOf(task._id);
// If for some reason the task isn't ordered (should never happen), push it in the new position
// if the task is moved to a non existing position
// or if the task is moved to position -1 (push to bottom)
// -> push task at end of list
if (!order[to] && to !== -1) {
order.push(task._id);
} else {
if (currentIndex !== -1) order.splice(currentIndex, 1);
if (to === -1) {
order.push(task._id);
} else {
order.splice(to, 0, task._id);
}
}
await user.save();
res.respond(200, order);
},
};
/**
* @api {post} /api/v3/tasks/:taskId/checklist Add an item to the task's checklist
* @apiVersion 3.0.0
* @apiName AddChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data The updated task
*/
api.addChecklistItem = {
method: 'POST',
url: '/tasks/:taskId/checklist',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
task.checklist.push(Tasks.Task.sanitizeChecklist(req.body));
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
/**
* @api {post} /api/v3/tasks/:taskId/checklist/:itemId/score Score a checklist item
* @apiVersion 3.0.0
* @apiName ScoreChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} itemId The checklist item _id
*
* @apiSuccess {object} data The updated task
*/
api.scoreCheckListItem = {
method: 'POST',
url: '/tasks/:taskId/checklist/:itemId/score',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
let item = _.find(task.checklist, {id: req.params.itemId});
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
item.completed = !item.completed;
let savedTask = await task.save();
res.respond(200, savedTask);
},
};
/**
* @api {put} /api/v3/tasks/:taskId/checklist/:itemId Update a checklist item
* @apiVersion 3.0.0
* @apiName UpdateChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} itemId The checklist item _id
*
* @apiSuccess {object} data The updated task
*/
api.updateChecklistItem = {
method: 'PUT',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
let item = _.find(task.checklist, {id: req.params.itemId});
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
_.merge(item, Tasks.Task.sanitizeChecklist(req.body));
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
/**
* @api {delete} /api/v3/tasks/:taskId/checklist/:itemId Remove a checklist item
* @apiVersion 3.0.0
* @apiName RemoveChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} itemId The checklist item _id
*
* @apiSuccess {object} data The updated task
*/
api.removeChecklistItem = {
method: 'DELETE',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
let hasItem = removeFromArray(task.checklist, { id: req.params.itemId });
if (!hasItem) throw new NotFound(res.t('checklistItemNotFound'));
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
/**
* @api {post} /api/v3/tasks/:taskId/tags/:tagId Add a tag to a task
* @apiVersion 3.0.0
* @apiName AddTagToTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} tagId The tag id
*
* @apiSuccess {object} data The updated task
*/
api.addTagToTask = {
method: 'POST',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let userTags = user.tags.map(tag => tag.id);
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
let tagId = req.params.tagId;
let alreadyTagged = task.tags.indexOf(tagId) !== -1;
if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged'));
task.tags.push(tagId);
let savedTask = await task.save();
res.respond(200, savedTask);
},
};
/**
* @api {delete} /api/v3/tasks/:taskId/tags/:tagId Remove a tag from atask
* @apiVersion 3.0.0
* @apiName RemoveTagFromTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} tagId The tag id
*
* @apiSuccess {object} data The updated task
*/
api.removeTagFromTask = {
method: 'DELETE',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
let hasTag = removeFromArray(task.tags, req.params.tagId);
if (!hasTag) throw new NotFound(res.t('tagNotFound'));
let savedTask = await task.save();
res.respond(200, savedTask);
},
};
/**
* @api {post} /api/v3/tasks/unlink-all/:challengeId Unlink all tasks from a challenge
* @apiVersion 3.0.0
* @apiName UnlinkAllTasks
* @apiGroup Task
*
* @apiParam {UUID} challengeId The challenge _id
* @apiParam {string} keep Query parameter - keep-all or remove-all
*
* @apiSuccess {object} data An empty object
*/
api.unlinkAllTasks = {
method: 'POST',
url: '/tasks/unlink-all/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkQuery('keep', res.t('keepOrRemoveAll')).notEmpty().isIn(['keep-all', 'remove-all']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let keep = req.query.keep;
let challengeId = req.params.challengeId;
let tasks = await Tasks.Task.find({
'challenge.id': challengeId,
userId: user._id,
}).exec();
let validTasks = tasks.every(task => {
return task.challenge.broken;
});
if (!validTasks) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
if (keep === 'keep-all') {
await Bluebird.all(tasks.map(task => {
task.challenge = {};
return task.save();
}));
} else { // remove
let toSave = [];
tasks.forEach(task => {
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
}
toSave.push(task.remove());
});
toSave.push(user.save());
await Bluebird.all(toSave);
}
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/tasks/unlink-one/:taskId Unlink a challenge task
* @apiVersion 3.0.0
* @apiName UnlinkOneTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {string} keep Query parameter - keep or remove
*
* @apiSuccess {object} data An empty object
*/
api.unlinkOneTask = {
method: 'POST',
url: '/tasks/unlink-one/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let keep = req.query.keep;
let taskId = req.params.taskId;
let task = await Tasks.Task.findOne({
_id: taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
if (!task.challenge.broken) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
if (keep === 'keep') {
task.challenge = {};
await task.save();
} else { // remove
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
removeFromArray(user.tasksOrder[`${task.type}s`], taskId);
await Bluebird.all([user.save(), task.remove()]);
} else {
await task.remove();
}
}
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/tasks/clearCompletedTodos Delete user's completed todos
* @apiVersion 3.0.0
* @apiName ClearCompletedTodos
* @apiGroup Task
*
* @apiSuccess {object} data An empty object
*/
api.clearCompletedTodos = {
method: 'POST',
url: '/tasks/clearCompletedTodos',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
// Clear completed todos
// Do not delete challenges completed todos unless the task is broken
await Tasks.Task.remove({
userId: user._id,
type: 'todo',
completed: true,
$or: [
{'challenge.id': {$exists: false}},
{'challenge.broken': {$exists: true}},
],
}).exec();
res.respond(200, {});
},
};
/**
* @api {delete} /api/v3/tasks/:taskId Delete a task given its id
* @apiVersion 3.0.0
* @apiName DeleteTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data An empty object
*/
api.deleteTask = {
method: 'DELETE',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let taskId = req.params.taskId;
let task = await Tasks.Task.findById(taskId).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
} else if (task.userId && task.challenge.id && !task.challenge.broken) {
throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
}
if (task.type !== 'todo' || !task.completed) {
removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId);
await Bluebird.all([(challenge || user).save(), task.remove()]);
} else {
await task.remove();
}
res.respond(200, {});
if (challenge) challenge.removeTask(task);
return null;
},
};
module.exports = api;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
let api = {};
// Internal authentication routes
// Logout the user from the website.
api.logout = {
method: 'GET',
url: '/logout',
async handler (req, res) {
if (req.logout) req.logout(); // passportjs method
req.session = null;
res.redirect('/');
},
};
module.exports = api;
@@ -0,0 +1,247 @@
import { authWithSession } from '../../middlewares/api-v3/auth';
import { model as User } from '../../models/user';
import * as Tasks from '../../models/task';
import {
NotFound,
} from '../../libs/api-v3/errors';
import _ from 'lodash';
import csvStringify from '../../libs/api-v3/csvStringify';
import moment from 'moment';
import js2xml from 'js2xmlparser';
import Pageres from 'pageres';
import AWS from 'aws-sdk';
import nconf from 'nconf';
import got from 'got';
import Bluebird from 'bluebird';
import locals from '../../middlewares/api-v3/locals';
let S3 = new AWS.S3({
accessKeyId: nconf.get('S3:accessKeyId'),
secretAccessKey: nconf.get('S3:secretAccessKey'),
});
const S3_BUCKET = nconf.get('S3:bucket');
const BASE_URL = nconf.get('BASE_URL');
let api = {};
/**
* @api {get} /export/history.csv Export user tasks history in CSV format
* @apiDescription History is only available for habits and dailys so todos and rewards won't be included. NOTE: Part of the private API that may change at any time.
* @apiVersion 3.0.0
* @apiName ExportUserHistory
* @apiGroup DataExport
*
* @apiSuccess {string} A cvs file
*/
api.exportUserHistory = {
method: 'GET',
url: '/export/history.csv',
middlewares: [authWithSession],
async handler (req, res) {
let user = res.locals.user;
let tasks = await Tasks.Task.find({
userId: user._id,
type: {$in: ['habit', 'daily']},
}).exec();
let output = [
['Task Name', 'Task ID', 'Task Type', 'Date', 'Value'],
];
tasks.forEach(task => {
task.history.forEach(history => {
output.push([
task.text,
task._id,
task.type,
moment(history.date).format('YYYY-MM-DD HH:mm:ss'),
history.value,
]);
});
});
res.set({
'Content-Type': 'text/csv',
'Content-disposition': 'attachment; filename=habitica-tasks-history.csv',
});
let csvRes = await csvStringify(output);
res.status(200).send(csvRes);
},
};
// Convert user to json and attach tasks divided by type
// at user.tasks[`${taskType}s`] (user.tasks.{dailys/habits/...})
async function _getUserDataForExport (user) {
let userData = user.toJSON();
userData.tasks = {};
let tasks = await Tasks.Task.find({
userId: user._id,
}).exec();
tasks = _.chain(tasks)
.map(task => task.toJSON())
.groupBy(task => task.type)
.each((tasksPerType, taskType) => {
userData.tasks[`${taskType}s`] = tasksPerType;
})
.value();
return userData;
}
/**
* @api {get} /export/userdata.json Export user data in JSON format
* @apiVersion 3.0.0
* @apiName ExportUserDataJson
* @apiGroup DataExport
* @apiDescription NOTE: Part of the private API that may change at any time.
*
* @apiSuccess {string} A json file
*/
api.exportUserDataJson = {
method: 'GET',
url: '/export/userdata.json',
middlewares: [authWithSession],
async handler (req, res) {
let userData = await _getUserDataForExport(res.locals.user);
res.set({
'Content-Type': 'application/json',
'Content-disposition': 'attachment; filename=habitica-user-data.json',
});
let jsonRes = JSON.stringify(userData);
res.status(200).send(jsonRes);
},
};
/**
* @api {get} /export/userdata.xml Export user data in XML format
* @apiVersion 3.0.0
* @apiName ExportUserDataXml
* @apiGroup DataExport
* @apiDescription NOTE: Part of the private API that may change at any time.
*
* @apiSuccess {string} A xml file
*/
api.exportUserDataXml = {
method: 'GET',
url: '/export/userdata.xml',
middlewares: [authWithSession],
async handler (req, res) {
let userData = await _getUserDataForExport(res.locals.user);
res.set({
'Content-Type': 'text/xml',
'Content-disposition': 'attachment; filename=habitica-user-data.xml',
});
res.status(200).send(js2xml('user', userData));
},
};
/**
* @api {get} /export/avatar-:uuid.html Render a user avatar as an HTML page
* @apiVersion 3.0.0
* @apiName ExportUserAvatarHtml
* @apiGroup DataExport
* @apiDescription NOTE: Part of the private API that may change at any time.
*
* @apiSuccess {string} An html page
*/
api.exportUserAvatarHtml = {
method: 'GET',
url: '/export/avatar-:memberId.html',
middlewares: [locals],
async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let memberId = req.params.memberId;
let member = await User
.findById(memberId)
.select('stats profile items achievements preferences backer contributor')
.exec();
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
res.render('avatar-static', {
title: member.profile.name,
env: _.defaults({user: member}, res.locals.habitrpg),
});
},
};
/**
* @api {get} /export/avatar-:uuid.html Export a user avatar as a PNG file
* @apiVersion 3.0.0
* @apiName ExportUserAvatarPng
* @apiGroup DataExport
* @apiDescription NOTE: Part of the private API that may change at any time.
*
* @apiSuccess {string} A png file
*/
api.exportUserAvatarPng = {
method: 'GET',
url: '/export/avatar-:memberId.png',
async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let memberId = req.params.memberId;
let filename = `avatars/${memberId}.png`;
let s3url = `https://${S3_BUCKET}.s3.amazonaws.com/${filename}`;
let response;
try {
response = await got.head(s3url);
} catch (gotError) {
if (gotError.code !== 'ENOTFOUND' && gotError.statusCode !== 404) {
throw gotError;
}
}
// cache images for 30 minutes on aws, else upload a new one
if (response && response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 30) {
return res.redirect(s3url);
}
let [stream] = await new Pageres()
.src(`${BASE_URL}/export/avatar-${memberId}.html`, ['140x147'], {
crop: true,
filename: filename.replace('.png', ''),
})
.run();
let s3upload = S3.upload({
Bucket: S3_BUCKET,
Key: filename,
ACL: 'public-read',
StorageClass: 'REDUCED_REDUNDANCY',
ContentType: 'image/png',
Expires: moment().add({minutes: 5}).toDate(),
Body: stream,
});
let s3res = await new Bluebird((resolve, reject) => {
s3upload.send((err, s3uploadRes) => {
if (err) {
reject(err);
} else {
resolve(s3uploadRes);
}
});
});
res.redirect(s3res.Location);
},
};
module.exports = api;
@@ -0,0 +1,54 @@
import { model as User } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { decrypt } from '../../libs/api-v3/encryption';
import {
NotFound,
} from '../../libs/api-v3/errors';
let api = {};
/**
* @api {get} /api/v3/email/unsubscribe Unsubscribe an email or user from email notifications
* @apiDescription Does not require authentication
* @apiVersion 3.0.0
* @apiName UnsubscribeEmail
* @apiGroup Unsubscribe
* @apiDescription This is a GET method so that you can put the unsubscribe link in emails.
*
* @apiParam {String} code Query parameter - An unsubscription code
*
* @apiSuccess {String} An html success message
*/
api.unsubscribe = {
method: 'GET',
url: '/email/unsubscribe',
async handler (req, res) {
req.checkQuery({
code: {
notEmpty: {errorMessage: res.t('missingUnsubscriptionCode')},
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let data = JSON.parse(decrypt(req.query.code));
if (data._id) {
let userUpdated = await User.update(
{_id: data._id},
{ $set: {'preferences.emailNotifications.unsubscribeFromAll': true}}
);
if (userUpdated.nModified !== 1) throw new NotFound(res.t('userNotFound'));
res.send(`<h1>${res.t('unsubscribedSuccessfully')}</h1> ${res.t('unsubscribedTextUsers')}`);
} else {
let unsubscribedEmail = await EmailUnsubscription.findOne({email: data.email.toLowerCase()});
let okResponse = `<h1>${res.t('unsubscribedSuccessfully')}</h1> ${res.t('unsubscribedTextOthers')}`;
if (!unsubscribedEmail) await EmailUnsubscription.create({email: data.email.toLowerCase()});
res.send(okResponse);
}
},
};
module.exports = api;
@@ -0,0 +1,79 @@
import locals from '../../middlewares/api-v3/locals';
import _ from 'lodash';
import markdownIt from 'markdown-it';
const md = markdownIt({
html: true,
});
let api = {};
const TOTAL_USER_COUNT = '1,100,000';
api.getFrontPage = {
method: 'GET',
url: '/',
middlewares: [locals],
runCron: false,
async handler (req, res) {
if (!req.header('x-api-user') && !req.header('x-api-key') && !(req.session && req.session.userId)) {
return res.redirect('/static/front');
}
return res.render('index.jade', {
title: 'Habitica | Your Life The Role Playing Game',
env: res.locals.habitrpg,
});
},
};
let staticPages = ['front', 'privacy', 'terms', 'api-v2', 'features',
'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines',
'old-news', 'press-kit', 'faq', 'overview', 'apps',
'clear-browser-data', 'merch', 'maintenance-info'];
_.each(staticPages, (name) => {
api[`get${name}Page`] = {
method: 'GET',
url: `/static/${name}`,
middlewares: [locals],
runCron: false,
async handler (req, res) {
return res.render(`static/${name}.jade`, {
env: res.locals.habitrpg,
md,
userCount: TOTAL_USER_COUNT,
});
},
};
});
let shareables = ['level-up', 'hatch-pet', 'raise-pet', 'unlock-quest', 'won-challenge', 'achievement'];
_.each(shareables, (name) => {
api[`get${name}ShareablePage`] = {
method: 'GET',
url: `/social/${name}`,
middlewares: [locals],
runCron: false,
async handler (req, res) {
return res.render(`social/${name}`, {
env: res.locals.habitrpg,
md,
userCount: TOTAL_USER_COUNT,
});
},
};
});
api.redirectExtensionsPage = {
method: 'GET',
url: '/static/extensions',
runCron: false,
async handler (req, res) {
return res.redirect('http://habitica.wikia.com/wiki/App_and_Extension_Integrations');
},
};
module.exports = api;
@@ -0,0 +1,256 @@
import {
BadRequest,
NotAuthorized,
} from '../../../libs/api-v3/errors';
import amzLib from '../../../libs/api-v3/amazonPayments';
import {
authWithHeaders,
authWithUrl,
} from '../../../middlewares/api-v3/auth';
import shared from '../../../../../common';
import payments from '../../../libs/api-v3/payments';
import moment from 'moment';
import { model as Coupon } from '../../../models/coupon';
import { model as User } from '../../../models/user';
import cc from 'coupon-code';
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token
* @apiVersion 3.0.0
* @apiName AmazonVerifyAccessToken
* @apiGroup Payments
*
* @apiSuccess {Object} data Empty object
**/
api.verifyAccessToken = {
method: 'POST',
url: '/amazon/verifyAccessToken',
middlewares: [authWithHeaders()],
async handler (req, res) {
let accessToken = req.body.access_token;
if (!accessToken) throw new BadRequest('Missing req.body.access_token');
await amzLib.getTokenInfo(accessToken);
res.respond(200, {});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id
* @apiVersion 3.0.0
* @apiName AmazonCreateOrderReferenceId
* @apiGroup Payments
*
* @apiSuccess {string} data.orderReferenceId The order reference id.
**/
api.createOrderReferenceId = {
method: 'POST',
url: '/amazon/createOrderReferenceId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let billingAgreementId = req.body.billingAgreementId;
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
let response = await amzLib.createOrderReferenceId({
Id: billingAgreementId,
IdType: 'BillingAgreement',
ConfirmNow: false,
});
res.respond(200, {
orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId,
});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/checkout Amazon Payments: checkout
* @apiVersion 3.0.0
* @apiName AmazonCheckout
* @apiGroup Payments
*
* @apiSuccess {object} data Empty object
**/
api.checkout = {
method: 'POST',
url: '/amazon/checkout',
middlewares: [authWithHeaders()],
async handler (req, res) {
let gift = req.body.gift;
let user = res.locals.user;
let orderReferenceId = req.body.orderReferenceId;
let amount = 5;
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
if (gift) {
if (gift.type === 'gems') {
amount = gift.gems.amount / 4;
} else if (gift.type === 'subscription') {
amount = shared.content.subscriptionBlocks[gift.subscription.key].price;
}
}
await amzLib.setOrderReferenceDetails({
AmazonOrderReferenceId: orderReferenceId,
OrderReferenceAttributes: {
OrderTotal: {
CurrencyCode: 'USD',
Amount: amount,
},
SellerNote: 'HabitRPG Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'HabitRPG',
},
},
});
await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId });
await amzLib.authorize({
AmazonOrderReferenceId: orderReferenceId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: amount,
},
SellerAuthorizationNote: 'HabitRPG Payment',
TransactionTimeout: 0,
CaptureNow: true,
});
await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId });
// execute payment
let method = 'buyGems';
let data = { user, paymentMethod: 'Amazon Payments' };
if (gift) {
if (gift.type === 'subscription') method = 'createSubscription';
gift.member = await User.findById(gift ? gift.uuid : undefined);
data.gift = gift;
data.paymentMethod = 'Gift';
}
await payments[method](data);
res.respond(200);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/subscribe Amazon Payments: subscribe
* @apiVersion 3.0.0
* @apiName AmazonSubscribe
* @apiGroup Payments
*
* @apiSuccess {object} data Empty object
**/
api.subscribe = {
method: 'POST',
url: '/amazon/subscribe',
middlewares: [authWithHeaders()],
async handler (req, res) {
let billingAgreementId = req.body.billingAgreementId;
let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
let coupon = req.body.coupon;
let user = res.locals.user;
if (!sub) throw new BadRequest(res.t('missingSubscriptionCode'));
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
if (sub.discount) { // apply discount
if (!coupon) throw new BadRequest(res.t('couponCodeRequired'));
let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key});
if (!result) throw new NotAuthorized(res.t('invalidCoupon'));
}
await amzLib.setBillingAgreementDetails({
AmazonBillingAgreementId: billingAgreementId,
BillingAgreementAttributes: {
SellerNote: 'HabitRPG Subscription',
SellerBillingAgreementAttributes: {
SellerBillingAgreementId: shared.uuid(),
StoreName: 'HabitRPG',
CustomInformation: 'HabitRPG Subscription',
},
},
});
await amzLib.confirmBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
await amzLib.authorizeOnBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: sub.price,
},
SellerAuthorizationNote: 'HabitRPG Subscription Payment',
TransactionTimeout: 0,
CaptureNow: true,
SellerNote: 'HabitRPG Subscription Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'HabitRPG',
},
});
await payments.createSubscription({
user,
customerId: billingAgreementId,
paymentMethod: 'Amazon Payments',
sub,
});
res.respond(200);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel
* @apiVersion 3.0.0
* @apiName AmazonSubscribe
* @apiGroup Payments
**/
api.subscribeCancel = {
method: 'GET',
url: '/amazon/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
let billingAgreementId = user.purchased.plan.customerId;
if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription'));
await amzLib.closeBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
await payments.cancelSubscription({
user,
nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }),
paymentMethod: 'Amazon Payments',
});
if (req.query.noRedirect) {
res.respond(200);
} else {
res.redirect('/');
}
},
};
module.exports = api;
@@ -0,0 +1,191 @@
import iap from 'in-app-purchase';
import nconf from 'nconf';
import {
authWithHeaders,
authWithUrl,
} from '../../../middlewares/api-v3/auth';
import payments from '../../../libs/api-v3/payments';
// NOT PORTED TO v3
iap.config({
// this is the path to the directory containing iap-sanbox/iap-live files
googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'),
});
// Validation ERROR Codes
const INVALID_PAYLOAD = 6778001;
// const CONNECTION_FAILED = 6778002;
// const PURCHASE_EXPIRED = 6778003;
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/android/verify Android Verify IAP
* @apiVersion 3.0.0
* @apiName IapAndroidVerify
* @apiGroup Payments
**/
api.iapAndroidVerify = {
method: 'POST',
url: '/iap/android/verify',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
let iapBody = req.body;
iap.setup((error) => {
if (error) {
let resObj = {
ok: false,
data: 'IAP Error',
};
return res.json(resObj);
}
// google receipt must be provided as an object
// {
// "data": "{stringified data object}",
// "signature": "signature from google"
// }
let testObj = {
data: iapBody.transaction.receipt,
signature: iapBody.transaction.signature,
};
// iap is ready
iap.validate(iap.GOOGLE, testObj, (err, googleRes) => {
if (err) {
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: err.toString(),
},
};
return res.json(resObj);
}
if (iap.isValidated(googleRes)) {
let resObj = {
ok: true,
data: googleRes,
};
payments.buyGems({
user,
paymentMethod: 'IAP GooglePlay',
amount: 5.25,
}).then(() => res.json(resObj));
}
});
});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/ios/verify iOS Verify IAP
* @apiVersion 3.0.0
* @apiName IapiOSVerify
* @apiGroup Payments
**/
api.iapiOSVerify = {
method: 'POST',
url: '/iap/ios/verify',
middlewares: [authWithHeaders()],
async handler (req, res) {
let iapBody = req.body;
let user = res.locals.user;
iap.setup(function iosSetupResult (error) {
if (error) {
let resObj = {
ok: false,
data: 'IAP Error',
};
return res.json(resObj);
}
// iap is ready
iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => {
if (err) {
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: err.toString(),
},
};
return res.json(resObj);
}
if (iap.isValidated(appleRes)) {
let purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length > 0) {
let correctReceipt = true;
for (let index in purchaseDataList) {
switch (purchaseDataList[index].productId) {
case 'com.habitrpg.ios.Habitica.4gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1});
break;
case 'com.habitrpg.ios.Habitica.8gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2});
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25});
break;
case 'com.habitrpg.ios.Habitica.42gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5});
break;
default:
correctReceipt = false;
}
}
if (correctReceipt) {
let resObj = {
ok: true,
data: appleRes,
};
// yay good!
return res.json(resObj);
}
}
// wrong receipt content
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: 'Incorrect receipt content',
},
};
return res.json(resObj);
}
// invalid receipt
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: 'Invalid receipt',
},
};
return res.json(resObj);
});
});
},
};
module.exports = api;
@@ -0,0 +1,278 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import moment from 'moment';
import _ from 'lodash';
import payments from '../../../libs/api-v3/payments';
import ipn from 'paypal-ipn';
import paypal from 'paypal-rest-sdk';
import shared from '../../../../../common';
import cc from 'coupon-code';
import Bluebird from 'bluebird';
import { model as Coupon } from '../../../models/coupon';
import { model as User } from '../../../models/user';
import {
authWithUrl,
authWithSession,
} from '../../../middlewares/api-v3/auth';
import {
BadRequest,
NotAuthorized,
} from '../../../libs/api-v3/errors';
const BASE_URL = nconf.get('BASE_URL');
// 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, (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'),
});
// TODO better handling of errors
const paypalPaymentCreate = Bluebird.promisify(paypal.payment.create, {context: paypal.payment});
const paypalPaymentExecute = Bluebird.promisify(paypal.payment.execute, {context: paypal.payment});
const paypalBillingAgreementCreate = Bluebird.promisify(paypal.billingAgreement.create, {context: paypal.billingAgreement});
const paypalBillingAgreementExecute = Bluebird.promisify(paypal.billingAgreement.execute, {context: paypal.billingAgreement});
const paypalBillingAgreementGet = Bluebird.promisify(paypal.billingAgreement.get, {context: paypal.billingAgreement});
const paypalBillingAgreementCancel = Bluebird.promisify(paypal.billingAgreement.cancel, {context: paypal.billingAgreement});
const ipnVerifyAsync = Bluebird.promisify(ipn.verify, {context: ipn});
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/checkout Paypal: checkout
* @apiVersion 3.0.0
* @apiName PaypalCheckout
* @apiGroup Payments
**/
api.checkout = {
method: 'GET',
url: '/paypal/checkout',
middlewares: [authWithUrl],
async handler (req, res) {
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
req.session.gift = req.query.gift;
let amount = 5.00;
let description = 'HabitRPG gems';
if (gift) {
if (gift.type === 'gems') {
amount = Number(gift.gems.amount / 4).toFixed(2);
description = `${description} (Gift)`;
} else {
amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
description = 'mo. HabitRPG Subscription (Gift)';
}
}
let createPayment = {
intent: 'sale',
payer: { payment_method: 'Paypal' },
redirect_urls: {
return_url: `${BASE_URL}/paypal/checkout/success`,
cancel_url: `${BASE_URL}`,
},
transactions: [{
item_list: {
items: [{
name: description,
// sku: 1,
price: amount,
currency: 'USD',
quantity: 1,
}],
},
amount: {
currency: 'USD',
total: amount,
},
description,
}],
};
let result = await paypalPaymentCreate(createPayment);
let link = _.find(result.links, { rel: 'approval_url' }).href;
res.redirect(link);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/checkout/success Paypal: checkout success
* @apiVersion 3.0.0
* @apiName PaypalCheckoutSuccess
* @apiGroup Payments
**/
api.checkoutSuccess = {
method: 'GET',
url: '/paypal/checkout/success',
middlewares: [authWithSession],
async handler (req, res) {
let paymentId = req.query.paymentId;
let customerId = req.query.payerID;
let method = 'buyGems';
let data = {
user: res.locals.user,
customerId,
paymentMethod: 'Paypal',
};
let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
delete req.session.gift;
if (gift) {
gift.member = await User.findById(gift.uuid);
if (gift.type === 'subscription') {
method = 'createSubscription';
}
data.paymentMethod = 'Gift';
data.gift = gift;
}
await paypalPaymentExecute(paymentId, { payer_id: customerId });
await payments[method](data);
res.redirect('/');
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe Paypal: subscribe
* @apiVersion 3.0.0
* @apiName PaypalSubscribe
* @apiGroup Payments
**/
api.subscribe = {
method: 'GET',
url: '/paypal/subscribe',
middlewares: [authWithUrl],
async handler (req, res) {
let sub = shared.content.subscriptionBlocks[req.query.sub];
if (sub.discount) {
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
if (!coupon) throw new NotAuthorized(res.t('invalidCoupon'));
}
let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`;
let billingAgreementAttributes = {
name: billingPlanTitle,
description: billingPlanTitle,
start_date: moment().add({ minutes: 5 }).format(),
plan: {
id: sub.paypalKey,
},
payer: {
payment_method: 'Paypal',
},
};
let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes);
req.session.paypalBlock = req.query.sub;
let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href;
res.redirect(link);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe/success Paypal: subscribe success
* @apiVersion 3.0.0
* @apiName PaypalSubscribeSuccess
* @apiGroup Payments
**/
api.subscribeSuccess = {
method: 'GET',
url: '/paypal/subscribe/success',
middlewares: [authWithSession],
async handler (req, res) {
let user = res.locals.user;
let block = shared.content.subscriptionBlocks[req.session.paypalBlock];
delete req.session.paypalBlock;
let result = await paypalBillingAgreementExecute(req.query.token, {});
await payments.createSubscription({
user,
customerId: result.id,
paymentMethod: 'Paypal',
sub: block,
});
res.redirect('/');
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe/cancel Paypal: subscribe cancel
* @apiVersion 3.0.0
* @apiName PaypalSubscribeCancel
* @apiGroup Payments
**/
api.subscribeCancel = {
method: 'GET',
url: '/paypal/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
let customerId = user.purchased.plan.customerId;
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
let customer = await paypalBillingAgreementGet(customerId);
let nextBillingDate = customer.agreement_details.next_billing_date;
if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet
throw new BadRequest(res.t('planNotActive', { nextBillingDate }));
}
await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') });
await payments.cancelSubscription({
user,
paymentMethod: 'Paypal',
nextBill: nextBillingDate,
});
res.redirect('/');
},
};
// General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their
// recurring paypal payments in their paypal dashboard. TODO ? Remove this when we can move to webhooks or some other solution
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /paypal/ipn Paypal IPN
* @apiVersion 3.0.0
* @apiName PaypalIpn
* @apiGroup Payments
**/
api.ipn = {
method: 'POST',
url: '/paypal/ipn',
async handler (req, res) {
res.sendStatus(200);
await ipnVerifyAsync(req.body);
if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') {
let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id });
if (user) {
await payments.cancelSubscription({ user, paymentMethod: 'Paypal' });
}
}
},
};
module.exports = api;
@@ -0,0 +1,169 @@
import stripeModule from 'stripe';
import shared from '../../../../../common';
import {
BadRequest,
NotAuthorized,
} from '../../../libs/api-v3/errors';
import { model as Coupon } from '../../../models/coupon';
import payments from '../../../libs/api-v3/payments';
import nconf from 'nconf';
import { model as User } from '../../../models/user';
import cc from 'coupon-code';
import {
authWithHeaders,
authWithUrl,
} from '../../../middlewares/api-v3/auth';
const stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /stripe/checkout Stripe checkout
* @apiVersion 3.0.0
* @apiName StripeCheckout
* @apiGroup Payments
*
* @apiParam {string} id Body parameter - The token
* @apiParam {string} email Body parameter - the customer email
* @apiParam {string} gift Query parameter - stringified json object, gift
* @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo
* @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions
*
* @apiSuccess {Object} data Empty object
**/
api.checkout = {
method: 'POST',
url: '/stripe/checkout',
middlewares: [authWithHeaders()],
async handler (req, res) {
let token = req.body.id;
let user = res.locals.user;
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
let coupon;
let response;
if (!token) throw new BadRequest('Missing req.body.id');
if (sub) {
if (sub.discount) {
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
if (!coupon) throw new BadRequest(res.t('invalidCoupon'));
}
response = await stripe.customers.create({
email: req.body.email,
metadata: { uuid: user._id },
card: token,
plan: sub.key,
});
} else {
let amount = 500; // $5
if (gift) {
if (gift.type === 'subscription') {
amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
} else {
amount = `${gift.gems.amount / 4 * 100}`;
}
}
response = await stripe.charges.create({
amount,
currency: 'usd',
card: token,
});
}
if (sub) {
await payments.createSubscription({
user,
customerId: response.id,
paymentMethod: 'Stripe',
sub,
});
} else {
let method = 'buyGems';
let data = {
user,
customerId: response.id,
paymentMethod: 'Stripe',
gift,
};
if (gift) {
let member = await User.findById(gift.uuid);
gift.member = member;
if (gift.type === 'subscription') method = 'createSubscription';
data.paymentMethod = 'Gift';
}
await payments[method](data);
}
res.respond(200, {});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /stripe/subscribe/edit Edit Stripe subscription
* @apiVersion 3.0.0
* @apiName StripeSubscribeEdit
* @apiGroup Payments
*
* @apiParam {string} id Body parameter - The token
*
* @apiSuccess {Object} data Empty object
**/
api.subscribeEdit = {
method: 'POST',
url: '/stripe/subscribe/edit',
middlewares: [authWithHeaders()],
async handler (req, res) {
let token = req.body.id;
let user = res.locals.user;
let customerId = user.purchased.plan.customerId;
if (!customerId) throw new NotAuthorized(res.t('missingSubscription'));
if (!token) throw new BadRequest('Missing req.body.id');
let subscriptions = await stripe.customers.listSubscriptions(customerId);
let subscriptionId = subscriptions.data[0].id;
await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token });
res.respond(200, {});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /stripe/subscribe/cancel Cancel Stripe subscription
* @apiVersion 3.0.0
* @apiName StripeSubscribeCancel
* @apiGroup Payments
**/
api.subscribeCancel = {
method: 'GET',
url: '/stripe/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId);
await stripe.customers.del(user.purchased.plan.customerId);
await payments.cancelSubscriptoin({
user,
nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds
paymentMethod: 'Stripe',
});
res.redirect('/');
},
};
module.exports = api;
+46
View File
@@ -0,0 +1,46 @@
'use strict';
/* eslint-disable global-require, no-process-env */
// Register babel hook so we can write the real entry file (server.js) in ES6
// In production, the es6 code is pre-transpiled so it doesn't need it
if (process.env.NODE_ENV !== 'production') {
require('babel-register');
}
// The BabelJS polyfill is needed in production too
require('babel-polyfill');
// Setup Bluebird as the global promise library
global.Promise = require('bluebird');
// Initialize configuration BEFORE anything
const setupNconf = require('./libs/api-v3/setupNconf');
setupNconf();
const nconf = require('nconf');
const cluster = require('cluster');
const logger = require('./libs/api-v3/logger');
const IS_PROD = nconf.get('IS_PROD');
const IS_DEV = nconf.get('IS_DEV');
const CORES = Number(nconf.get('WEB_CONCURRENCY')) || 0;
// Initialize New Relic
if (IS_PROD && nconf.get('NEW_RELIC_ENABLED') === 'true') require('newrelic');
// Setup the cluster module
if (CORES !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) {
// Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production)
for (let i = 0; i < CORES; i += 1) {
cluster.fork();
}
cluster.on('disconnect', function onWorkerDisconnect (worker) {
let w = cluster.fork(); // replace the dead worker
logger.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid);
});
} else {
module.exports = require('./server.js');
}
+205
View File
@@ -0,0 +1,205 @@
require('./i18n');
var _ = require('lodash');
var Content = require('../../../../common').content;
var Amplitude = require('amplitude');
var googleAnalytics = require('universal-analytics');
var ga;
var amplitude;
var analytics = {
trackPurchase: trackPurchase,
track: track
}
function init(options) {
if(!options) { throw 'No options provided' }
amplitude = new Amplitude(options.amplitudeToken);
ga = googleAnalytics(options.googleAnalytics);
return analytics;
}
function track(eventType, data) {
_sendDataToAmplitude(eventType, data);
_sendDataToGoogle(eventType, data);
}
function _sendDataToAmplitude(eventType, data) {
var amplitudeData = _formatDataForAmplitude(data);
amplitudeData.event_type = eventType;
amplitude.track(amplitudeData).catch(function(error) {
// @TODO log error with new relic
});
}
function _sendDataToGoogle(eventType, data) {
var eventData = {
ec: data.category,
ea: eventType
}
var label = _generateLabelForGoogleAnalytics(data);
if(label) { eventData.el = label; }
var value = _generateValueForGoogleAnalytics(data);
if(value) { eventData.ev = value; }
ga.event(eventData).send();
}
function _generateLabelForGoogleAnalytics(data) {
var label;
var POSSIBLE_LABELS = ['gaLabel', 'itemKey'];
_(POSSIBLE_LABELS).each(function(key) {
if(data[key]) {
label = data[key];
return false; // exit _.each early
}
}).value();
return label;
}
function _generateValueForGoogleAnalytics(data) {
var value;
var POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost'];
_(POSSIBLE_VALUES).each(function(key) {
if(data[key]) {
value = data[key];
return false; // exit _.each early
}
}).value();
return value;
}
function trackPurchase(data) {
_sendPurchaseDataToAmplitude(data);
_sendPurchaseDataToGoogle(data);
}
function _sendPurchaseDataToAmplitude(data) {
var amplitudeData = _formatDataForAmplitude(data);
amplitudeData.event_type = 'purchase';
amplitudeData.revenue = data.purchaseValue;
amplitude.track(amplitudeData).catch(function(error) {
// @TODO log error with new relic
});
}
function _formatDataForAmplitude(data) {
var PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue'];
var event_properties = _.omit(data, PROPERTIES_TO_SCRUB);
var ampData = {
user_id: data.uuid || 'no-user-id-was-provided',
platform: 'server',
event_properties: event_properties
}
if(data.user) {
ampData.user_properties = _formatUserData(data.user);
}
var itemName = _lookUpItemName(data.itemKey);
if(itemName) {
event_properties.itemName = itemName;
}
return ampData;
}
function _lookUpItemName(itemKey) {
if (!itemKey) return;
var gear = Content.gear.flat[itemKey];
var egg = Content.eggs[itemKey];
var food = Content.food[itemKey];
var hatchingPotion = Content.hatchingPotions[itemKey];
var quest = Content.quests[itemKey];
var spell = Content.special[itemKey];
var itemName;
if (gear) {
itemName = gear.text();
} else if (egg) {
itemName = egg.text() + ' Egg';
} else if (food) {
itemName = food.text();
} else if (hatchingPotion) {
itemName = hatchingPotion.text() + " Hatching Potion";
} else if (quest) {
itemName = quest.text();
} else if (spell) {
itemName = spell.text();
}
return itemName;
}
function _formatUserData(user) {
var properties = {};
if (user.stats) {
properties.Class = user.stats.class;
properties.Experience = Math.floor(user.stats.exp);
properties.Gold = Math.floor(user.stats.gp);
properties.Health = Math.ceil(user.stats.hp);
properties.Level = user.stats.lvl;
properties.Mana = Math.floor(user.stats.mp);
}
properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2;
if (user.habits && user.dailys && user.todos && user.rewards) {
properties["Number Of Tasks"] = {
habits: user.habits.length,
dailys: user.dailys.length,
todos: user.todos.length,
rewards: user.rewards.length
};
}
if (user.contributor && user.contributor.level) {
properties.contributorLevel = user.contributor.level;
}
if (user.purchased && user.purchased.plan.planId) {
properties.subscription = user.purchased.plan.planId;
}
return properties;
}
function _sendPurchaseDataToGoogle(data) {
var label = data.paymentMethod;
var type = data.purchaseType;
var price = data.purchaseValue;
var qty = data.quantity;
var sku = data.sku;
var itemKey = data.itemPurchased;
var variation = type;
if(data.gift) variation += ' - Gift';
var eventData = {
ec: 'commerce',
ea: type,
el: label,
ev: price
};
ga.event(eventData).send();
ga.transaction(data.uuid, price)
.item(price, qty, sku, itemKey, variation)
.send();
}
module.exports = init;
@@ -0,0 +1,59 @@
var fs = require('fs');
var path = require('path');
var nconf = require('nconf');
var _ = require('lodash');
var manifestFiles = require("../../../client/manifest.json");
var IS_PROD = nconf.get('NODE_ENV') === 'production';
var buildFiles = [];
var walk = function(folder){
var res = fs.readdirSync(folder);
res.forEach(function(fileName){
var 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 = module.exports.getBuildUrl = function(url){
if(buildFiles[url]) return '/' + buildFiles[url];
return '/' + url;
};
module.exports.getManifestFiles = function(page){
var files = manifestFiles[page];
if(!files) throw new Error("Page not found!");
var code = '';
if(IS_PROD){
code += '<link rel="stylesheet" type="text/css" href="' + getBuildUrl(page + '.css') + '">';
code += '<script type="text/javascript" src="' + getBuildUrl(page + '.js') + '"></script>';
}else{
_.each(files.css, function(file){
code += '<link rel="stylesheet" type="text/css" href="' + getBuildUrl(file) + '">';
});
_.each(files.js, function(file){
code += '<script type="text/javascript" src="' + getBuildUrl(file) + '"></script>';
});
}
return code;
};
+83
View File
@@ -0,0 +1,83 @@
var Firebase = require('firebase');
var nconf = require('nconf');
var isProd = nconf.get('NODE_ENV') === 'production';
var firebaseConfig = nconf.get('FIREBASE');
var firebaseRef;
var isFirebaseEnabled = (nconf.get('NODE_ENV') === 'production') && (firebaseConfig.ENABLED === 'true');
import { TAVERN_ID } from '../../models/group';
// Setup
if(isFirebaseEnabled){
firebaseRef = new Firebase('https://' + firebaseConfig.APP + '.firebaseio.com');
// TODO what happens if an op is sent before client is authenticated?
firebaseRef.authWithCustomToken(firebaseConfig.SECRET, function(err, authData){
// TODO it's ok to kill the server here? what if FB is offline?
if(err) throw new Error('Impossible to authenticate Firebase');
});
}
var api = module.exports = {};
api.updateGroupData = function(group){
if(!isFirebaseEnabled) return;
// TODO is throw ok? we don't have callbacks
if(!group) throw new Error('group is required.');
// Return in case of tavern (comparison working because we use string for _id)
if(group._id === TAVERN_ID) return;
firebaseRef.child('rooms/' + group._id)
.set({
name: group.name
});
};
api.addUserToGroup = function(groupId, userId){
if(!isFirebaseEnabled) return;
if(!userId || !groupId) throw new Error('groupId, userId are required.');
if(groupId === TAVERN_ID) return;
firebaseRef.child('members/' + groupId + '/' + userId)
.set(true);
firebaseRef.child('users/' + userId + '/rooms/' + groupId)
.set(true);
};
api.removeUserFromGroup = function(groupId, userId){
if(!isFirebaseEnabled) return;
if(!userId || !groupId) throw new Error('groupId, userId are required.');
if(groupId === TAVERN_ID) return;
firebaseRef.child('members/' + groupId + '/' + userId)
.remove();
firebaseRef.child('users/' + userId + '/rooms/' + groupId)
.remove();
};
api.deleteGroup = function(groupId){
if(!isFirebaseEnabled) return;
if(!groupId) throw new Error('groupId is required.');
if(groupId === TAVERN_ID) return;
firebaseRef.child('rooms/' + groupId)
.remove();
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted (/members/... in future...)
firebaseRef.child('members/' + groupId)
.remove();
};
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted
api.deleteUser = function(userId){
if(!isFirebaseEnabled) return;
if(!userId) throw new Error('userId is required.');
firebaseRef.child('users/' + userId)
.remove();
};
+180
View File
@@ -0,0 +1,180 @@
var fs = require('fs'),
path = require('path'),
_ = require('lodash'),
User = require('../../models/user').model,
accepts = require('accepts'),
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');
// A list of languages that have different versions
var multipleVersionsLanguages = ['es', 'zh'];
var latinAmericanSpanishes = {
'es-419': 'es_419',
'es-mx': 'es_419',
'es-gt': 'es_419',
'es-cr': 'es_419',
'es-pa': 'es_419',
'es-do': 'es_419',
'es-ve': 'es_419',
'es-co': 'es_419',
'es-pe': 'es_419',
'es-ar': 'es_419',
'es-ec': 'es_419',
'es-cl': 'es_419',
'es-uy': 'es_419',
'es-py': 'es_419',
'es-bo': 'es_419',
'es-sv': 'es_419',
'es-hn': 'es_419',
'es-ni': 'es_419',
'es-pr': 'es_419',
};
var chineseVersions = {
'zh-tw': 'zh_TW',
};
var getUserLanguage = function(req, res, next){
var getFromBrowser = function(){
var acceptedLanguages = accepts(req).languages();
var acceptable = _(acceptedLanguages).map(function(lang){
return lang.slice(0, 2);
}).uniq().value();
var matches = _.intersection(acceptable, defaultLangCodes);
var iAcceptedCompleteLang = (matches.length > 0) ? multipleVersionsLanguages.indexOf(matches[0].toLowerCase()) : -1;
if(iAcceptedCompleteLang !== -1){
var acceptedCompleteLang = _.find(acceptedLanguages, function(accepted){
return accepted.slice(0, 2) == multipleVersionsLanguages[iAcceptedCompleteLang];
});
if(acceptedCompleteLang){
acceptedCompleteLang = acceptedCompleteLang.toLowerCase();
}else{
return 'en';
}
if(matches[0] === 'es'){
return latinAmericanSpanishes[acceptedCompleteLang] || 'es';
}else if(matches[0] === 'zh'){
return chineseVersions[acceptedCompleteLang] || 'zh';
}else{
return en;
}
}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}, 'preferences.language', 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 middlewares/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);
};
+57
View File
@@ -0,0 +1,57 @@
var nconf = require('nconf');
var winston = require('winston');
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) {
logger = new (winston.Logger)({});
logger.add(winston.transports.Console, {colorize:true}); // TODO remove
if (nconf.get('NODE_ENV') !== 'production') {
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);
};
+204
View File
@@ -0,0 +1,204 @@
var nodemailer = require('nodemailer');
var nconf = require('nconf');
var crypto = require('crypto');
var path = require("path");
var request = require('request');
const IS_PROD = nconf.get('IS_PROD');
const BASE_URL = nconf.get('BASE_URL');
module.exports.sendEmail = function(mailData) {
var smtpTransport = nodemailer.createTransport({
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('./api-v2/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.profile.name || user.auth.facebook.displayName || user.auth.facebook.username;
}
}
if(fields.indexOf('email') != -1){
if(user.auth.local && user.auth.local.email){
info.email = user.auth.local.email;
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
info.email = user.auth.facebook.emails[0].value;
}
}
if(fields.indexOf('_id') != -1){
info._id = user._id;
}
if(fields.indexOf('canSend') != -1){
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
}
return info;
}
module.exports.getUserInfo = getUserInfo;
module.exports.txnEmail = function(mailingInfoArray, emailType, variables, personalVariables){
var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
var variables = [
{name: 'BASE_URL', content: BASE_URL}
].concat(variables || []);
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
mailingInfoArray = mailingInfoArray.map(function(mailingInfo){
return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo;
}).filter(function(mailingInfo){
// Always send reset-password emails
// Don't check canSend for non registered users as already checked before
return (mailingInfo.email && ((!mailingInfo._id || mailingInfo.canSend) || emailType === 'reset-password'));
});
// Personal variables are personal to each email recipient, if they are missing
// we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL
// otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables
if(!personalVariables || personalVariables.length === 0){
personalVariables = mailingInfoArray.map(function(mailingInfo){
return {
rcpt: mailingInfo.email,
vars: [
{
name: 'RECIPIENT_NAME',
content: mailingInfo.name
},
{
name: 'RECIPIENT_UNSUB_URL',
content: '/unsubscribe?code=' + module.exports.encrypt(JSON.stringify({
_id: mailingInfo._id,
email: mailingInfo.email
}))
}
]
}
});
}else{
var temporaryPersonalVariables = {};
mailingInfoArray.forEach(function(mailingInfo){
temporaryPersonalVariables[mailingInfo.email] = {
name: mailingInfo.name,
_id: mailingInfo._id
}
});
personalVariables.forEach(function(singlePersonalVariables){
singlePersonalVariables.vars.push(
{
name: 'RECIPIENT_NAME',
content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name
},
{
name: 'RECIPIENT_UNSUB_URL',
content: '/unsubscribe?code=' + module.exports.encrypt(JSON.stringify({
_id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id,
email: singlePersonalVariables.rcpt
}))
}
)
});
}
if(IS_PROD && 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,
personalVariables: personalVariables
},
options: {
priority: 'high',
attempts: 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);
}
// Prepare to export analytics object
// Export emoty methods until the right ones are ready
module.exports.analytics = { track: function() { }, trackPurchase: function() { } };
/**
* Load nconf and define default configuration values if config.json or ENV vars are not found
*/
module.exports.setupConfig = function(){
if (nconf.get('IS_DEV'))
Error.stackTraceLimit = Infinity;
if (IS_PROD && nconf.get('NEW_RELIC_ENABLED') === 'true')
require('newrelic');
var analytics = IS_PROD && require('./api-v2/analytics');
var analyticsTokens = {
amplitudeToken: nconf.get('AMPLITUDE_KEY'),
googleAnalytics: nconf.get('GA_ID')
}
if(analytics){
analytics = analytics(analyticsTokens);
// Use the right analytics methods, don't substitute the entire object
// or all the require() across the code will keep the empty methods
module.exports.analytics.track = analytics.track;
module.exports.analytics.trackPurchase = analytics.trackPurchase;
}
};
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;
}
+24
View File
@@ -0,0 +1,24 @@
var _ = require('lodash');
var request = require('request');
var validator = require('validator');
function sendTaskWebhook(webhooks, data) {
_.each(webhooks, function(hook){
if (!hook.enabled || !validator.isURL(hook.url)) return;
request.post({
url: hook.url,
body: {
direction: data.task.direction,
task: data.task.details,
delta: data.task.delta,
user: data.user
},
json: true
});
});
}
module.exports = {
sendTaskWebhook: sendTaskWebhook
};
@@ -0,0 +1,62 @@
import amazonPayments from 'amazon-payments';
import nconf from 'nconf';
import common from '../../../../common';
import Bluebird from 'bluebird';
import {
BadRequest,
} from './errors';
// TODO better handling of errors
const i18n = common.i18n;
const IS_PROD = nconf.get('NODE_ENV') === 'production';
let amzPayment = amazonPayments.connect({
environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'],
sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'),
mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'),
clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'),
});
let getTokenInfo = Bluebird.promisify(amzPayment.api.getTokenInfo, {context: amzPayment.api});
let createOrderReferenceId = Bluebird.promisify(amzPayment.offAmazonPayments.createOrderReferenceForId, {context: amzPayment.offAmazonPayments});
let setOrderReferenceDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setOrderReferenceDetails, {context: amzPayment.offAmazonPayments});
let confirmOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.confirmOrderReference, {context: amzPayment.offAmazonPayments});
let closeOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.closeOrderReference, {context: amzPayment.offAmazonPayments});
let setBillingAgreementDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setBillingAgreementDetails, {context: amzPayment.offAmazonPayments});
let confirmBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.confirmBillingAgreement, {context: amzPayment.offAmazonPayments});
let closeBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.closeBillingAgreement, {context: amzPayment.offAmazonPayments});
let authorizeOnBillingAgreement = (inputSet) => {
return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => {
if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
return resolve(response);
});
});
};
let authorize = (inputSet) => {
return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => {
if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
return resolve(response);
});
});
};
module.exports = {
getTokenInfo,
createOrderReferenceId,
setOrderReferenceDetails,
confirmOrderReference,
closeOrderReference,
confirmBillingAgreement,
setBillingAgreementDetails,
closeBillingAgreement,
authorizeOnBillingAgreement,
authorize,
};
@@ -0,0 +1,237 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import Bluebird from 'bluebird';
import googleAnalytics from 'universal-analytics';
import {
each,
omit,
} from 'lodash';
import { content as Content } from '../../../../common';
const AMPLIUDE_TOKEN = nconf.get('AMPLITUDE_KEY');
const GA_TOKEN = nconf.get('GA_ID');
const GA_POSSIBLE_LABELS = ['gaLabel', 'itemKey'];
const GA_POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost'];
const AMPLITUDE_PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue'];
let amplitude = new Amplitude(AMPLIUDE_TOKEN);
let ga = googleAnalytics(GA_TOKEN);
let _lookUpItemName = (itemKey) => {
if (!itemKey) return;
let gear = Content.gear.flat[itemKey];
let egg = Content.eggs[itemKey];
let food = Content.food[itemKey];
let hatchingPotion = Content.hatchingPotions[itemKey];
let quest = Content.quests[itemKey];
let spell = Content.special[itemKey];
let itemName;
if (gear) {
itemName = gear.text();
} else if (egg) {
itemName = `${egg.text()} Egg`;
} else if (food) {
itemName = food.text();
} else if (hatchingPotion) {
itemName = `${hatchingPotion.text()} Hatching Potion`;
} else if (quest) {
itemName = quest.text();
} else if (spell) {
itemName = spell.text();
}
return itemName;
};
let _formatUserData = (user) => {
let properties = {};
if (user.stats) {
properties.Class = user.stats.class;
properties.Experience = Math.floor(user.stats.exp);
properties.Gold = Math.floor(user.stats.gp);
properties.Health = Math.ceil(user.stats.hp);
properties.Level = user.stats.lvl;
properties.Mana = Math.floor(user.stats.mp);
}
properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2;
if (user.habits && user.dailys && user.todos && user.rewards) {
properties['Number Of Tasks'] = {
habits: user.habits.length,
dailys: user.dailys.length,
todos: user.todos.length,
rewards: user.rewards.length,
};
}
if (user.contributor && user.contributor.level) {
properties.contributorLevel = user.contributor.level;
}
if (user.purchased && user.purchased.plan.planId) {
properties.subscription = user.purchased.plan.planId;
}
return properties;
};
let _formatDataForAmplitude = (data) => {
let event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB);
let ampData = {
user_id: data.uuid || 'no-user-id-was-provided',
platform: 'server',
event_properties,
};
if (data.user) {
ampData.user_properties = _formatUserData(data.user);
}
let itemName = _lookUpItemName(data.itemKey);
if (itemName) {
event_properties.itemName = itemName;
}
return ampData;
};
let _sendDataToAmplitude = (eventType, data) => {
let amplitudeData = _formatDataForAmplitude(data);
amplitudeData.event_type = eventType;
return new Bluebird((resolve, reject) => {
amplitude.track(amplitudeData)
.then(resolve)
.catch(reject);
});
};
let _generateLabelForGoogleAnalytics = (data) => {
let label;
each(GA_POSSIBLE_LABELS, (key) => {
if (data[key]) {
label = data[key];
return false; // exit each early
}
});
return label;
};
let _generateValueForGoogleAnalytics = (data) => {
let value;
each(GA_POSSIBLE_VALUES, (key) => {
if (data[key]) {
value = data[key];
return false; // exit each early
}
});
return value;
};
let _sendDataToGoogle = (eventType, data) => {
let eventData = {
ec: data.category,
ea: eventType,
};
let label = _generateLabelForGoogleAnalytics(data);
if (label) {
eventData.el = label;
}
let value = _generateValueForGoogleAnalytics(data);
if (value) {
eventData.ev = value;
}
return new Bluebird((resolve, reject) => {
ga.event(eventData, (err) => {
if (err) return reject(err);
resolve();
});
});
};
let _sendPurchaseDataToAmplitude = (data) => {
let amplitudeData = _formatDataForAmplitude(data);
amplitudeData.event_type = 'purchase';
amplitudeData.revenue = data.purchaseValue;
return new Bluebird((resolve, reject) => {
amplitude.track(amplitudeData)
.then(resolve)
.catch(reject);
});
};
let _sendPurchaseDataToGoogle = (data) => {
let label = data.paymentMethod;
let type = data.purchaseType;
let price = data.purchaseValue;
let qty = data.quantity;
let sku = data.sku;
let itemKey = data.itemPurchased;
let variation = type;
if (data.gift) variation += ' - Gift';
let eventData = {
ec: 'commerce',
ea: type,
el: label,
ev: price,
};
return new Bluebird((resolve) => {
ga.event(eventData).send();
ga.transaction(data.uuid, price)
.item(price, qty, sku, itemKey, variation)
.send();
resolve();
});
};
function track (eventType, data) {
return Bluebird.all([
_sendDataToAmplitude(eventType, data),
_sendDataToGoogle(eventType, data),
]);
}
function trackPurchase (data) {
return Bluebird.all([
_sendPurchaseDataToAmplitude(data),
_sendPurchaseDataToGoogle(data),
]);
}
// Stub for non-prod environments
let mockAnalyticsService = {
track: () => { },
trackPurchase: () => { },
};
module.exports = {
track,
trackPurchase,
mockAnalyticsService,
};
+79
View File
@@ -0,0 +1,79 @@
import { v4 as uuid } from 'uuid';
import validator from 'validator';
import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out
import _ from 'lodash';
module.exports = function baseModel (schema, options = {}) {
if (options._id !== false) {
schema.add({
_id: {
type: String,
default: uuid,
validate: [validator.isUUID, 'Invalid uuid.'],
},
});
}
if (options.timestamps) {
schema.add({
createdAt: {
type: Date,
default: Date.now,
},
updatedAt: {
type: Date,
default: Date.now,
},
});
}
if (options.timestamps) {
schema.pre('save', function updateUpdatedAt (next) {
if (!this.isNew) this.updatedAt = Date.now();
next();
});
schema.pre('update', function preUpdateModel () {
this.update({}, { $set: { updatedAt: new Date() } });
});
}
let noSetFields = ['createdAt', 'updatedAt'];
let privateFields = ['__v'];
if (Array.isArray(options.noSet)) noSetFields.push(...options.noSet);
// This method accepts an additional array of fields to be sanitized that can be passed at runtime
schema.statics.sanitize = function sanitize (objToSanitize = {}, additionalFields = []) {
noSetFields.concat(additionalFields).forEach((fieldPath) => {
objectPath.del(objToSanitize, fieldPath);
});
// Allow a sanitize transform function to be used
return options.sanitizeTransform ? options.sanitizeTransform(objToSanitize) : objToSanitize;
};
if (Array.isArray(options.private)) privateFields.push(...options.private);
if (!schema.options.toJSON) schema.options.toJSON = {};
schema.options.toJSON.transform = function transformToObject (doc, plainObj) {
privateFields.forEach((fieldPath) => {
objectPath.del(plainObj, fieldPath);
});
// Always return `id`
if (!plainObj.id && plainObj._id) plainObj.id = plainObj._id;
// Allow an additional toJSON transform function to be used
return options.toJSONTransform ? options.toJSONTransform(plainObj, doc) : plainObj;
};
schema.statics.getModelPaths = function getModelPaths () {
return _.reduce(this.schema.paths, (result, field, path) => {
if (privateFields.indexOf(path) === -1) {
result[path] = field.instance || 'Boolean';
}
return result;
}, {});
};
};
@@ -0,0 +1,62 @@
import fs from 'fs';
import path from 'path';
import nconf from 'nconf';
const MANIFEST_FILE_PATH = path.join(__dirname, '/../../../client/manifest.json');
const BUILD_FOLDER_PATH = path.join(__dirname, '/../../../build');
let manifestFiles = require(MANIFEST_FILE_PATH);
const IS_PROD = nconf.get('IS_PROD');
let buildFiles = [];
function _walk (folder) {
let files = fs.readdirSync(folder);
files.forEach((fileName) => {
let file = `${folder}/${fileName}`;
if (fs.statSync(file).isDirectory()) {
_walk(file);
} else {
let relFolder = path.relative(BUILD_FOLDER_PATH, folder);
let original = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); // Match the hash part of the filename
if (relFolder) {
original = `${relFolder}/${original}`;
fileName = `${relFolder}/${fileName}`;
}
buildFiles[original] = fileName;
}
});
}
// Walks through all the files in the build directory
// and creates a map of original files names and hashed files names
_walk(BUILD_FOLDER_PATH);
export function getBuildUrl (url) {
return `/${buildFiles[url] || url}`;
}
export function getManifestFiles (page) {
let files = manifestFiles[page];
if (!files) throw new Error(`Page "${page}" not found!`);
let htmlCode = '';
if (IS_PROD) {
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(page + '.css')}">`; // eslint-disable-line prefer-template
htmlCode += `<script type="text/javascript" src="${getBuildUrl(page + '.js')}"></script>`; // eslint-disable-line prefer-template
} else {
files.css.forEach((file) => {
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(file)}">`;
});
files.js.forEach((file) => {
htmlCode += `<script type="text/javascript" src="${getBuildUrl(file)}"></script>`;
});
}
return htmlCode;
}
@@ -0,0 +1,22 @@
import {
findIndex,
isPlainObject,
} from 'lodash';
export function removeFromArray (array, element) {
let elementIndex;
if (isPlainObject(element)) {
elementIndex = findIndex(array, element);
} else {
elementIndex = array.indexOf(element);
}
if (elementIndex !== -1) {
let removedElement = array[elementIndex];
array.splice(elementIndex, 1);
return removedElement;
}
return false;
}
+280
View File
@@ -0,0 +1,280 @@
import moment from 'moment';
import common from '../../../../common/';
import { preenUserHistory } from '../../libs/api-v3/preening';
import _ from 'lodash';
import nconf from 'nconf';
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
const shouldDo = common.shouldDo;
const scoreTask = common.ops.scoreTask;
// const maxPMs = 200;
let CLEAR_BUFFS = {
str: 0,
int: 0,
per: 0,
con: 0,
stealth: 0,
streaks: false,
};
function grantEndOfTheMonthPerks (user, now) {
let plan = user.purchased.plan;
if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) {
plan.gemsBought = 0; // reset gem-cap
plan.dateUpdated = now;
// For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks
// If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0
// TODO use month diff instead of ++ / --? see https://github.com/HabitRPG/habitrpg/issues/4317
_.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0});
plan.consecutive.count++;
if (plan.consecutive.offset > 0) {
plan.consecutive.offset--;
} else if (plan.consecutive.count % 3 === 0) { // every 3 months
plan.consecutive.trinkets++;
plan.consecutive.gemCapExtra += 5;
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25)
}
}
}
function removeTerminatedSubscription (user) {
// If subscription's termination date has arrived
let plan = user.purchased.plan;
if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) {
_.merge(plan, {
planId: null,
customerId: null,
paymentMethod: null,
});
_.merge(plan.consecutive, {
count: 0,
offset: 0,
gemCapExtra: 0,
});
user.markModified('purchased.plan');
}
}
function performSleepTasks (user, tasksByType, now) {
user.stats.buffs = _.cloneDeep(CLEAR_BUFFS);
tasksByType.dailys.forEach((daily) => {
let completed = daily.completed;
let thatDay = moment(now).subtract({days: 1});
if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) {
// TODO also untick checklists if the Daily was due on previous missed days, if two or more days were missed at once -- https://github.com/HabitRPG/habitrpg/pull/7218#issuecomment-219256016
daily.checklist.forEach(box => box.completed = false);
}
daily.completed = false;
});
}
// Perform various beginning-of-day reset actions.
export function cron (options = {}) {
let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options;
user.auth.timestamps.loggedin = now;
user.lastCron = now;
user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
// User is only allowed a certain number of drops a day. This resets the count.
if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
// "Perfect Day" achievement for perfect-days
let perfect = true;
if (user.isSubscribed()) {
grantEndOfTheMonthPerks(user, now);
if (!CRON_SAFE_MODE) removeTerminatedSubscription(user);
}
// User is resting at the inn.
// On cron, buffs are cleared and all dailies are reset without performing damage
if (user.preferences.sleep === true) {
performSleepTasks(user, tasksByType, now);
return;
}
let multiDaysCountAsOneDay = true;
// If the user does not log in for two or more days, cron (mostly) acts as if it were only one day.
// When site-wide difficulty settings are introduced, this can be a user preference option.
// Tally each task
let todoTally = 0;
tasksByType.todos.forEach(task => { // make uncompleted To-Dos redder (further incentive to complete them)
scoreTask({
task,
user,
direction: 'down',
cron: true,
times: multiDaysCountAsOneDay ? 1 : daysMissed,
});
todoTally += task.value;
});
// For incomplete Dailys, add value (further incentive), deduct health, keep records for later decreasing the nightly mana gain
let dailyChecked = 0; // how many dailies were checked?
let dailyDueUnchecked = 0; // how many dailies were un-checked?
if (!user.party.quest.progress.down) user.party.quest.progress.down = 0;
tasksByType.dailys.forEach((task) => {
let completed = task.completed;
// Deduct points for missed Daily tasks
let EvadeTask = 0;
let scheduleMisses = daysMissed;
if (completed) {
dailyChecked += 1;
} else {
// dailys repeat, so need to calculate how many they've missed according to their own schedule
scheduleMisses = 0;
for (let i = 0; i < daysMissed; i++) {
let thatDay = moment(now).subtract({days: i + 1});
if (shouldDo(thatDay.toDate(), task, user.preferences)) {
scheduleMisses++;
if (user.stats.buffs.stealth) {
user.stats.buffs.stealth--;
EvadeTask++;
}
if (multiDaysCountAsOneDay) break;
}
}
if (scheduleMisses > EvadeTask) {
// The user did not complete this due Daily (but no penalty if cron is running in safe mode).
if (CRON_SAFE_MODE) {
dailyChecked += 1; // allows full allotment of mp to be gained
} else {
perfect = false;
if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points
let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length;
dailyDueUnchecked += 1 - fractionChecked;
dailyChecked += fractionChecked;
} else {
dailyDueUnchecked += 1;
}
let delta = scoreTask({
user,
task,
direction: 'down',
times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask,
cron: true,
});
// Apply damage from a boss, less damage for Trivial priority (difficulty)
user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1);
// NB: Medium and Hard priorities do not increase damage from boss. This was by accident
// initially, and when we realised, we could not fix it because users are used to
// their Medium and Hard Dailies doing an Easy amount of damage from boss.
// Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future
// setting between Trivial and Easy.
}
}
}
task.history.push({
date: Number(new Date()),
value: task.value,
});
task.completed = false;
if (completed || scheduleMisses > 0) {
task.checklist.forEach(i => i.completed = false);
}
});
// move singleton Habits towards yellow.
tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0
if (task.up === false || task.down === false) {
task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2;
}
});
// Finished tallying
user.history.todos.push({date: now, value: todoTally});
// tally experience
let expTally = user.stats.exp;
let lvl = 0; // iterator
while (lvl < user.stats.lvl - 1) {
lvl++;
expTally += common.tnl(lvl);
}
user.history.exp.push({date: now, value: expTally});
// preen user history so that it doesn't become a performance problem
// also for subscribed users but differently
// TODO also do while resting in the inn. Note that later we'll be allowing the value/color of tasks to change while sleeping (https://github.com/HabitRPG/habitrpg/issues/5232), so the code in performSleepTasks() might be best merged back into here for that. Perhaps wait until then to do preen history for sleeping users.
preenUserHistory(user, tasksByType, user.preferences.timezoneOffset);
if (perfect) {
user.achievements.perfect++;
let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2);
user.stats.buffs = {
str: lvlDiv2,
int: lvlDiv2,
per: lvlDiv2,
con: lvlDiv2,
stealth: 0,
streaks: false,
};
} else {
user.stats.buffs = _.cloneDeep(CLEAR_BUFFS);
}
// Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit
// Adjust for fraction of dailies completed
if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1;
user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP;
// After all is said and done, progress up user's effect on quest, return those values & reset the user's
let progress = user.party.quest.progress;
let _progress = _.cloneDeep(progress);
_.merge(progress, {down: 0, up: 0});
progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
// TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn
// let numberOfPMs = Object.keys(user.inbox.messages).length;
// if (numberOfPMs > maxPMs) {
// _(user.inbox.messages)
// .sortBy('timestamp')
// .takeRight(numberOfPMs - maxPMs)
// .each(pm => {
// delete user.inbox.messages[pm.id];
// }).value();
//
// user.markModified('inbox.messages');
// }
// Analytics
user.flags.cronCount++;
analytics.track('Cron', { // TODO also do while resting in the inn. https://github.com/HabitRPG/habitrpg/issues/7161#issuecomment-218214191
category: 'behavior',
gaLabel: 'Cron Count',
gaValue: user.flags.cronCount,
uuid: user._id,
user,
resting: user.preferences.sleep,
cronCount: user.flags.cronCount,
progressUp: _.min([_progress.up, 900]),
progressDown: _progress.down,
});
return _progress;
}
@@ -0,0 +1,11 @@
import csvStringify from 'csv-stringify';
import Bluebird from 'bluebird';
module.exports = (input) => {
return new Bluebird((resolve, reject) => {
csvStringify(input, (err, output) => {
if (err) return reject(err);
return resolve(output);
});
});
};
+156
View File
@@ -0,0 +1,156 @@
import { createTransport } from 'nodemailer';
import nconf from 'nconf';
import { encrypt } from './encryption';
import request from 'request';
import logger from './logger';
const IS_PROD = nconf.get('IS_PROD');
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER:url'),
auth: {
user: nconf.get('EMAIL_SERVER:authUser'),
password: nconf.get('EMAIL_SERVER:authPassword'),
},
};
const BASE_URL = nconf.get('BASE_URL');
let smtpTransporter = createTransport({
service: nconf.get('SMTP_SERVICE'),
auth: {
user: nconf.get('SMTP_USER'),
pass: nconf.get('SMTP_PASS'),
},
});
// Send email directly from the server using the smtpTransporter,
// used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them
export function send (mailData) {
return smtpTransporter.sendMail(mailData); // promise
}
export function getUserInfo (user, fields = []) {
let info = {};
if (fields.indexOf('name') !== -1) {
info.name = user.profile && user.profile.name;
if (!info.name) {
if (user.auth.local && user.auth.local.username) {
info.name = user.auth.local.username;
} else if (user.auth.facebook) {
info.name = user.auth.facebook.displayName || user.auth.facebook.username;
}
}
}
if (fields.indexOf('email') !== -1) {
if (user.auth.local && user.auth.local.email) {
info.email = user.auth.local.email;
} else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value) {
info.email = user.auth.facebook.emails[0].value;
}
}
if (fields.indexOf('_id') !== -1) {
info._id = user._id;
}
if (fields.indexOf('canSend') !== -1) {
if (user.preferences && user.preferences.emailNotifications) {
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
}
}
return info;
}
// Send a transactional email using Mandrill through the external email server
export function sendTxn (mailingInfoArray, emailType, variables, personalVariables) {
mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
variables = [
{name: 'BASE_URL', content: BASE_URL},
].concat(variables || []);
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
mailingInfoArray = mailingInfoArray.map((mailingInfo) => {
return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo;
}).filter((mailingInfo) => {
// Always send reset-password emails
// Don't check canSend for non registered users as already checked before
return mailingInfo.email && (!mailingInfo._id || mailingInfo.canSend || emailType === 'reset-password');
});
// Personal variables are personal to each email recipient, if they are missing
// we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL
// otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables
if (!personalVariables || personalVariables.length === 0) {
personalVariables = mailingInfoArray.map((mailingInfo) => {
return {
rcpt: mailingInfo.email,
vars: [
{
name: 'RECIPIENT_NAME',
content: mailingInfo.name,
},
{
name: 'RECIPIENT_UNSUB_URL',
content: `/email/unsubscribe?code=${encrypt(JSON.stringify({
_id: mailingInfo._id,
email: mailingInfo.email,
}))}`,
},
],
};
});
} else {
let temporaryPersonalVariables = {};
mailingInfoArray.forEach((mailingInfo) => {
temporaryPersonalVariables[mailingInfo.email] = {
name: mailingInfo.name,
_id: mailingInfo._id,
};
});
personalVariables.forEach((singlePersonalVariables) => {
singlePersonalVariables.vars.push(
{
name: 'RECIPIENT_NAME',
content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name,
},
{
name: 'RECIPIENT_UNSUB_URL',
content: `/email/unsubscribe?code=${encrypt(JSON.stringify({
_id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id,
email: singlePersonalVariables.rcpt,
}))}`,
}
);
});
}
if (IS_PROD && mailingInfoArray.length > 0) {
request.post({
url: `${EMAIL_SERVER.url}/job`,
auth: {
user: EMAIL_SERVER.auth.user,
pass: EMAIL_SERVER.auth.password,
},
json: {
type: 'email',
data: {
emailType,
to: mailingInfoArray,
variables,
personalVariables,
},
options: {
priority: 'high',
attempts: 5,
backoff: {delay: 10 * 60 * 1000, type: 'fixed'},
},
},
}, (err) => logger.error(err));
}
}
+24
View File
@@ -0,0 +1,24 @@
import {
createCipher,
createDecipher,
} from 'crypto';
import nconf from 'nconf';
const algorithm = 'aes-256-ctr';
const SESSION_SECRET = nconf.get('SESSION_SECRET');
export function encrypt (text) {
let cipher = createCipher(algorithm, SESSION_SECRET);
let crypted = cipher.update(text, 'utf8', 'hex');
crypted += cipher.final('hex');
return crypted;
}
export function decrypt (text) {
let decipher = createDecipher(algorithm, SESSION_SECRET);
let dec = decipher.update(text, 'hex', 'utf8');
dec += decipher.final('utf8');
return dec;
}
+62
View File
@@ -0,0 +1,62 @@
import common from '../../../../common';
export const CustomError = common.errors.CustomError;
/**
* @apiDefine NotAuthorized
* @apiError NotAuthorized The client is not authorized to make this request.
*
* @apiErrorExample Error-Response:
* HTTP/1.1 401 Unauthorized
* {
* "error": "NotAuthorized",
* "message": "Not authorized."
* }
*/
export const NotAuthorized = common.errors.NotAuthorized;
/**
* @apiDefine BadRequest
* @apiError BadRequest The request wasn't formatted correctly.
*
* @apiErrorExample Error-Response:
* HTTP/1.1 400 Bad Request
* {
* "error": "BadRequest",
* "message": "Bad request."
* }
*/
export const BadRequest = common.errors.BadRequest;
/**
* @apiDefine NotFound
* @apiError NotFound The requested resource was not found.
*
* @apiErrorExample Error-Response:
* HTTP/1.1 404 Not Found
* {
* "error": "NotFound",
* "message": "Not found."
* }
*/
export const NotFound = common.errors.NotFound;
/**
* @apiDefine InternalServerError
* @apiError InternalServerError An unexpected error occurred.
*
* @apiErrorExample Error-Response:
* HTTP/1.1 500 Internal Server Error
* {
* "error": "InternalServerError",
* "message": "An unexpected error occurred."
* }
*/
export class InternalServerError extends CustomError {
constructor (customMessage) {
super();
this.name = this.constructor.name;
this.httpCode = 500;
this.message = customMessage || 'An unexpected error occurred.';
}
}
+69
View File
@@ -0,0 +1,69 @@
import Firebase from 'firebase';
import nconf from 'nconf';
import { TAVERN_ID } from '../../models/group';
const FIREBASE_CONFIG = nconf.get('FIREBASE');
const FIREBASE_ENABLED = FIREBASE_CONFIG.ENABLED === 'true';
let firebaseRef;
if (FIREBASE_ENABLED) {
firebaseRef = new Firebase(`https://${FIREBASE_CONFIG.APP}.firebaseio.com`);
// TODO what happens if an op is sent before client is authenticated?
firebaseRef.authWithCustomToken(FIREBASE_CONFIG.SECRET, (err) => {
// TODO it's ok to kill the server here? what if FB is offline?
if (err) throw new Error('Impossible to authenticate Firebase');
});
}
export function updateGroupData (group) {
if (!FIREBASE_ENABLED) return;
// TODO is throw ok? we don't have callbacks
if (!group) throw new Error('group obj is required.');
// Return in case of tavern (comparison working because we use string for _id)
if (group._id === TAVERN_ID) return;
firebaseRef.child(`rooms/${group._id}`)
.set({
name: group.name,
});
}
export function addUserToGroup (groupId, userId) {
if (!FIREBASE_ENABLED) return;
if (!userId || !groupId) throw new Error('groupId, userId are required.');
if (groupId === TAVERN_ID) return;
firebaseRef.child(`members/${groupId}/${userId}`).set(true);
firebaseRef.child(`users/${userId}/rooms/${groupId}`).set(true);
}
export function removeUserFromGroup (groupId, userId) {
if (!FIREBASE_ENABLED) return;
if (!userId || !groupId) throw new Error('groupId, userId are required.');
if (groupId === TAVERN_ID) return;
firebaseRef.child(`members/${groupId}/${userId}`).remove();
firebaseRef.child(`users/${userId}/rooms/${groupId}`).remove();
}
export function deleteGroup (groupId) {
if (!FIREBASE_ENABLED) return;
if (!groupId) throw new Error('groupId is required.');
if (groupId === TAVERN_ID) return;
firebaseRef.child(`members/${groupId}`).remove();
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted (/members/... in future...)
firebaseRef.child(`rooms/${groupId}`).remove();
}
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted
export function deleteUser (userId) {
if (!FIREBASE_ENABLED) return;
if (!userId) throw new Error('userId is required.');
firebaseRef.child(`users/${userId}`).remove();
}
+105
View File
@@ -0,0 +1,105 @@
import fs from 'fs';
import path from 'path';
import _ from 'lodash';
import shared from '../../../../common';
export const localePath = path.join(__dirname, '/../../../../common/locales/');
// Store translations
export let translations = {};
// Store MomentJS localization files
export let momentLangs = {};
// Handle differencies in language codes between MomentJS and /locales
let momentLangsMapping = {
en: 'en-gb',
en_GB: 'en-gb', // eslint-disable-line camelcase
no: 'nn',
zh: 'zh-cn',
es_419: 'es', // eslint-disable-line camelcase
};
function _loadTranslations (locale) {
let files = fs.readdirSync(path.join(localePath, locale));
translations[locale] = {};
files.forEach((file) => {
if (path.extname(file) !== '.json') return;
// We use require to load and parse a JSON file
_.merge(translations[locale], require(path.join(localePath, locale, file))); // eslint-disable-line global-require
});
}
// First fetch English strings so we can merge them with missing strings in other languages
_loadTranslations('en');
// Then load all other languages
fs.readdirSync(localePath).forEach((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);
});
// Add translations to shared
shared.i18n.translations = translations;
export let langCodes = Object.keys(translations);
export let availableLanguages = langCodes.map((langCode) => {
return {
code: langCode,
name: translations[langCode].languageName,
};
});
langCodes.forEach((code) => {
let lang = _.find(availableLanguages, {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
// We wrap everything in a try catch because the file might not exist
let f = fs.readFileSync(path.join(__dirname, `/../../../node_modules/moment/locale/${lang.momentLangCode}.js`), 'utf8');
momentLangs[code] = f;
} catch (e) { // eslint-disable-lint no-empty
// The catch block is mandatory so it won't crash the server
}
});
// Remove en_GB from langCodes checked by browser to avoid it being
// used in place of plain original 'en' (it's an optional language that can be enabled only in setting)
export let defaultLangCodes = _.without(langCodes, 'en_GB');
// A map of languages that have different versions and the relative versions
export let multipleVersionsLanguages = {
es: {
'es-419': 'es_419',
'es-mx': 'es_419',
'es-gt': 'es_419',
'es-cr': 'es_419',
'es-pa': 'es_419',
'es-do': 'es_419',
'es-ve': 'es_419',
'es-co': 'es_419',
'es-pe': 'es_419',
'es-ar': 'es_419',
'es-ec': 'es_419',
'es-cl': 'es_419',
'es-uy': 'es_419',
'es-py': 'es_419',
'es-bo': 'es_419',
'es-sv': 'es_419',
'es-hn': 'es_419',
'es-ni': 'es_419',
'es-pr': 'es_419',
},
zh: {
'zh-tw': 'zh_TW',
},
};
+60
View File
@@ -0,0 +1,60 @@
// Logger utility
import winston from 'winston';
import nconf from 'nconf';
import _ from 'lodash';
const IS_PROD = nconf.get('IS_PROD');
const IS_TEST = nconf.get('IS_TEST');
const ENABLE_CONSOLE_LOGS_IN_PROD = nconf.get('ENABLE_CONSOLE_LOGS_IN_PROD') === 'true';
const logger = new winston.Logger();
if (IS_PROD) {
if (ENABLE_CONSOLE_LOGS_IN_PROD) {
logger.add(winston.transports.Console, {
colorize: true,
prettyPrint: true,
});
}
} else if (IS_TEST) {
// Do not log anything when testing
} else {
logger
.add(winston.transports.Console, {
colorize: true,
prettyPrint: true,
});
}
// exports a public interface insteaf of accessing directly the logger module
let loggerInterface = {
info (...args) {
logger.info(...args);
},
// Accepts two argument,
// an Error object (required)
// and an object of additional data to log alongside the error
// If the first argument isn't an Error, it'll call logger.error with all the arguments supplied
error (...args) {
let [err, errorData = {}, ...otherArgs] = args;
if (err instanceof Error) {
// pass the error stack as the first parameter to logger.error
let stack = err.stack || err.message || err;
if (_.isPlainObject(errorData) && !errorData.fullError) errorData.fullError = err;
logger.error(stack, errorData, ...otherArgs);
} else {
logger.error(...args);
}
},
};
// Logs unhandled promises errors
// when no catch is attached to a promise a unhandledRejection event will be triggered
process.on('unhandledRejection', function handlePromiseRejection (reason) {
loggerInterface.error(reason);
});
module.exports = loggerInterface;
+18
View File
@@ -0,0 +1,18 @@
// Utilities for working with passwords
import crypto from 'crypto';
// Return the encrypted version of a password (using sha1) given a salt
export function encrypt (password, salt) {
return crypto
.createHmac('sha1', salt)
.update(password)
.digest('hex');
}
// Create a salt, default length is 10
export function makeSalt (len = 10) {
return crypto
.randomBytes(Math.ceil(len / 2))
.toString('hex')
.substring(0, len);
}
+185
View File
@@ -0,0 +1,185 @@
import _ from 'lodash' ;
import analytics from './analyticsService';
import {
getUserInfo,
sendTxn as txnEmail,
} from './email';
import members from '../../controllers/api-v3/members';
import moment from 'moment';
import nconf from 'nconf';
import pushNotify from './pushNotifications';
import shared from '../../../../common' ;
const IS_PROD = nconf.get('IS_PROD');
let api = {};
function revealMysteryItems (user) {
_.each(shared.content.gear.flat, function findMysteryItems (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) !== -1
) {
user.purchased.plan.mysteryItems.push(item.key);
}
});
}
api.createSubscription = async function createSubscription (data) {
let recipient = data.gift ? data.gift.member : data.user;
let plan = recipient.purchased.plan;
let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key];
let months = Number(block.months);
if (data.gift) {
if (plan.customerId && !plan.dateTerminated) { // User has active plan
plan.extraMonths += months;
} else {
plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate();
if (!plan.dateUpdated) plan.dateUpdated = new Date();
}
if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId
} else {
_(plan).merge({ // override with these values
planId: block.key,
customerId: data.customerId,
dateUpdated: new Date(),
gemsBought: 0,
paymentMethod: data.paymentMethod,
extraMonths: Number(plan.extraMonths) +
Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0),
dateTerminated: null,
// Specify a lastBillingDate just for Amazon Payments
// Resetted every time the subscription restarts
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined,
}).defaults({ // allow non-override if a plan was previously used
dateCreated: new Date(),
mysteryItems: [],
}).value();
}
// Block sub perks
let perks = Math.floor(months / 3);
if (perks) {
plan.consecutive.offset += months;
plan.consecutive.gemCapExtra += perks * 5;
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25;
plan.consecutive.trinkets += perks;
}
revealMysteryItems(recipient);
if (IS_PROD) {
if (!data.gift) txnEmail(data.user, 'subscription-begins');
analytics.trackPurchase({
uuid: data.user._id,
itemPurchased: 'Subscription',
sku: `${data.paymentMethod.toLowerCase()}-subscription`,
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: block.price,
});
}
data.user.purchased.txnCount++;
if (data.gift) {
members.sendMessage(data.user, data.gift.member, data.gift);
let byUserName = getUserInfo(data.user, ['name']).name;
if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) {
txnEmail(data.gift.member, 'gifted-subscription', [
{name: 'GIFTER', content: byUserName},
{name: 'X_MONTHS_SUBSCRIPTION', content: months},
]);
}
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`);
}
}
await data.user.save();
if (data.gift) await data.gift.member.save();
};
// Sets their subscription to be cancelled later
api.cancelSubscription = async function cancelSubscription (data) {
let plan = data.user.purchased.plan;
let now = moment();
let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30;
let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`;
let nowStrFormat = 'MM/DD/YYYY';
plan.dateTerminated =
moment(nowStr, nowStrFormat)
.add({days: remaining}) // end their subscription 1mo from their last payment
.add({days: Math.ceil(30 * plan.extraMonths)}) // plus any extra time (carry-over, gifted subscription, etc) they have.
.toDate();
plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
await data.user.save();
txnEmail(data.user, 'cancel-subscription');
analytics.track('unsubscribe', {
uuid: data.user._id,
gaCategory: 'commerce',
gaLabel: data.paymentMethod,
paymentMethod: data.paymentMethod,
});
};
api.buyGems = async function buyGems (data) {
let amt = data.amount || 5;
amt = data.gift ? data.gift.gems.amount / 4 : amt;
(data.gift ? data.gift.member : data.user).balance += amt;
data.user.purchased.txnCount++;
if (IS_PROD) {
if (!data.gift) txnEmail(data.user, 'donation');
analytics.trackPurchase({
uuid: data.user._id,
itemPurchased: 'Gems',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
purchaseType: 'checkout',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: amt,
});
}
if (data.gift) {
let byUsername = getUserInfo(data.user, ['name']).name;
let gemAmount = data.gift.gems.amount || 20;
members.sendMessage(data.user, data.gift.member, data.gift);
if (data.gift.member.preferences.emailNotifications.giftedGems !== false) {
txnEmail(data.gift.member, 'gifted-gems', [
{name: 'GIFTER', content: byUsername},
{name: 'X_GEMS_GIFTED', content: gemAmount},
]);
}
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`);
}
await data.gift.member.save();
}
await data.user.save();
};
module.exports = api;
+82
View File
@@ -0,0 +1,82 @@
import _ from 'lodash';
import moment from 'moment';
// Aggregate entries
function _aggregate (history, aggregateBy) {
return _.chain(history)
.groupBy(entry => { // group entries by aggregateBy
return moment(entry.date).format(aggregateBy);
})
.sortBy((entry, key) => key) // sort by date
.map(entries => {
return {
date: Number(entries[0].date),
value: _.reduce(entries, (previousValue, entry) => {
return previousValue + entry.value;
}, 0) / entries.length,
};
})
.value();
}
/* Preen an array of history entries
Free users:
- 1 value for each day of the past 60 days (no compression)
- 1 value each month for the previous 10 months
- 1 value each year for the previous years
Subscribers and challenges:
- 1 value for each day of the past 365 days (no compression)
- 1 value each month for the previous 12 months
- 1 value each year for the previous years
*/
export function preenHistory (history, isSubscribed, timezoneOffset) {
// history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries
let now = timezoneOffset ? moment().zone(timezoneOffset) : moment();
// Date after which to begin compressing data
let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day');
// Keep uncompressed entries (modifies history and returns removed items)
let newHistory = _.remove(history, entry => {
let date = moment(entry.date);
return date.isSame(cutOff) || date.isAfter(cutOff);
});
// Date after which to begin compressing data by year
let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day');
let aggregateByMonth = _.remove(history, entry => {
let date = moment(entry.date);
return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff);
});
// Aggregate remaining entries by month and year
if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM'));
if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY'));
return newHistory;
}
// Preen history for users and tasks.
export function preenUserHistory (user, tasksByType) {
let isSubscribed = user.isSubscribed();
let timezoneOffset = user.preferences.timezoneOffset;
let minHistoryLength = isSubscribed ? 365 : 60;
function _processTask (task) {
if (task.history && task.history.length > minHistoryLength) {
task.history = preenHistory(task.history, isSubscribed, timezoneOffset);
task.markModified('history');
}
}
tasksByType.habits.forEach(_processTask);
tasksByType.dailys.forEach(_processTask);
if (user.history.exp.length > minHistoryLength) {
user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset);
user.markModified('history.exp');
}
if (user.history.todos.length > minHistoryLength) {
user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset);
user.markModified('history.todos');
}
}
@@ -0,0 +1,53 @@
import _ from 'lodash';
import nconf from 'nconf';
import pushNotify from 'push-notify';
const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY');
let gcm = GCM_API_KEY ? pushNotify.gcm({
apiKey: GCM_API_KEY,
retries: 3,
}) : undefined;
// TODO review and test this file when push notifications are added back
if (gcm) {
gcm.on('transmitted', (/* result, message, registrationId */) => {
// console.info("transmitted", result, message, registrationId);
});
gcm.on('transmissionError', (/* error, message, registrationId */) => {
// console.info("transmissionError", error, message, registrationId);
});
gcm.on('updated', (/* result, registrationId */) => {
// console.info("updated", result, registrationId);
});
}
module.exports = function sendNotification (user, title, message, timeToLive = 15) {
if (!user) return;
_.each(user.pushDevices, pushDevice => {
switch (pushDevice.type) {
case 'android':
if (gcm) {
gcm.send({
registrationId: pushDevice.regId,
// collapseKey: 'COLLAPSE_KEY',
delayWhileIdle: true,
timeToLive,
data: {
title,
message,
},
});
}
break;
case 'ios':
break;
}
});
};
+61
View File
@@ -0,0 +1,61 @@
import fs from 'fs';
import _ from 'lodash';
import {
getUserLanguage,
} from '../../middlewares/api-v3/language';
import cron from '../../middlewares/api-v3/cron';
// Wrapper function to handler `async` route handlers that return promises
// It takes the async function, execute it and pass any error to next (args[2])
let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]);
let noop = (req, res, next) => next();
module.exports.readController = function readController (router, controller) {
_.each(controller, (action) => {
let {method, url, middlewares = [], handler, runCron} = action;
// If an authentication middleware is used run getUserLanguage after it, otherwise before
// for cron instead use it only if an authentication middleware is present
let authMiddlewareIndex = _.findIndex(middlewares, middleware => {
if (middleware.name.indexOf('authWith') === 0) { // authWith{Headers|Session|Url|...}
return true;
} else {
return false;
}
});
let middlewaresToAdd = [getUserLanguage];
if (authMiddlewareIndex !== -1) { // the user will be authenticated, getUserLanguage and cron after authentication
if (!(runCron === false)) { // eslint-disable-line no-extra-parens
middlewaresToAdd.push(cron);
}
if (authMiddlewareIndex === middlewares.length - 1) {
middlewares.push(...middlewaresToAdd);
} else {
middlewares.splice(authMiddlewareIndex + 1, 0, ...middlewaresToAdd);
}
} else { // no auth, getUserLanguage as the first middleware
middlewares.unshift(...middlewaresToAdd);
}
method = method.toLowerCase();
let fn = handler ? _wrapAsyncFn(handler) : noop;
router[method](url, ...middlewares, fn);
});
};
module.exports.walkControllers = function walkControllers (router, filePath) {
fs
.readdirSync(filePath)
.forEach(fileName => {
if (!fs.statSync(filePath + fileName).isFile()) {
walkControllers(router, `${filePath}${fileName}/`);
} else if (fileName.match(/\.js$/)) {
let controller = require(filePath + fileName); // eslint-disable-line global-require
module.exports.readController(router, controller);
}
});
};
@@ -0,0 +1,28 @@
import nconf from 'nconf';
import logger from './logger';
import autoinc from 'mongoose-id-autoinc';
import mongoose from 'mongoose';
import Bluebird from 'bluebird';
const IS_PROD = nconf.get('IS_PROD');
const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE');
// Use Q promises instead of mpromise in mongoose
mongoose.Promise = Bluebird;
// Do not connect to MongoDB when in maintenance mode
if (MAINTENANCE_MODE !== 'true') {
let mongooseOptions = !IS_PROD ? {} : {
replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } },
server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } },
};
const NODE_DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI');
let db = mongoose.connect(NODE_DB_URI, mongooseOptions, (err) => {
if (err) throw err;
logger.info('Connected with Mongoose.');
});
autoinc.init(db);
}
+17
View File
@@ -0,0 +1,17 @@
import nconf from 'nconf';
import { join, resolve } from 'path';
const PATH_TO_CONFIG = join(resolve(__dirname, '../../../../config.json'));
module.exports = function setupNconf (file) {
let configFile = file || PATH_TO_CONFIG;
nconf
.argv()
.env()
.file('user', configFile);
nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production');
nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development');
nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test');
};
@@ -0,0 +1,24 @@
import passport from 'passport';
import nconf from 'nconf';
import passportFacebook from 'passport-facebook';
const FacebookStrategy = passportFacebook.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((user, done) => done(null, user));
passport.deserializeUser((obj, done) => done(null, obj));
// TODO remove?
// 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"
}, (accessToken, refreshToken, profile, done) => done(null, profile)));
+31
View File
@@ -0,0 +1,31 @@
import { each } from 'lodash';
import { post } from 'request';
import { isURL } from 'validator';
import logger from './logger';
let _sendWebhook = (url, body) => {
post({
url,
body,
json: true,
}, (err) => logger.error(err));
};
let _isInvalidWebhook = (hook) => {
return !hook.enabled || !isURL(hook.url);
};
export function sendTaskWebhook (webhooks, data) {
each(webhooks, (hook) => {
if (_isInvalidWebhook(hook)) return;
let body = {
direction: data.task.direction,
task: data.task.details,
delta: data.task.delta,
user: data.user,
};
_sendWebhook(hook.url, body);
});
}
@@ -0,0 +1,49 @@
var nconf = require('nconf');
var moment = require('moment');
var domainMiddleware = require('domain-middleware');
var os = require('os');
var request = require('request');
var IS_PROD = nconf.get('NODE_ENV') === 'production';
module.exports = function(server,mongoose) {
/* if (IS_PROD) {
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,
memory = os.freemem() / os.totalmem(),
memoryHigh = memory < 0.1;
if (memoryHigh) {
var newRelicMemoryLeakMessage = '[Memory Leak] Apdex='+score+' Memory='+parseFloat(memory).toFixed(3)+' Time='+moment().format();
throw newRelicMemoryLeakMessage;
}
});
var memory = os.freemem() / os.totalmem(),
memoryHigh = memory < 0.1;
if (memoryHigh) {
var memoryLeakMessage = '[Memory Leak] Memory='+parseFloat(memory).toFixed(3)+' Time='+moment().format();
throw memoryLeakMessage;
}
}, mins*60*1000);
} */
return domainMiddleware({
server: {
close:function(){
server.close();
mongoose.connection.close();
}
},
killTimeout: 10000
});
};
@@ -0,0 +1,24 @@
var logging = require('../../libs/api-v2/logging');
module.exports = 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.status(500).json({err:message}); //res.end(err.message);
};
@@ -0,0 +1,70 @@
var nconf = require('nconf');
var _ = require('lodash');
var utils = require('../libs/api-v2/utils');
var shared = require('../../../common');
var i18n = require('../libs/api-v2/i18n');
var buildManifest = require('../libs/api-v2/buildManifest');
var shared = require('../../../common');
var forceRefresh = require('./forceRefresh');
var tavernQuest = require('../models/group').tavernQuest;
var mods = require('../models/user').mods;
// To avoid stringifying more data then we need,
// items from `env` used on the client will have to be specified in this array
var clientVars = ['language', 'isStaticPage', 'avalaibleLanguages', 'translations',
'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID',
'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY',
'worldDmg', 'mods', 'IS_MOBILE'];
var env = {
getManifestFiles: buildManifest.getManifestFiles,
getBuildUrl: buildManifest.getBuildUrl,
_: _,
clientVars: clientVars,
mods: mods,
Content: shared.content,
siteVersion: forceRefresh.siteVersion,
avalaibleLanguages: i18n.avalaibleLanguages,
AMAZON_PAYMENTS: {
SELLER_ID: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
CLIENT_ID: nconf.get('AMAZON_PAYMENTS:CLIENT_ID')
}
};
'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(function(key){
env[key] = nconf.get(key);
});
module.exports = 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);
res.locals.habitrpg = _.assign(env, {
IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')),
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);
},
// Defined here and not outside of the middleware because tavernQuest might be an
// empty object until the query to fetch it finishes
worldDmg: (tavernQuest && tavernQuest.extra && tavernQuest.extra.worldDmg) || {},
});
// Put query-string party (& guild but use partyInvite for backward compatibility)
// invitations into session to be handled later
// TODO once we have proper logging in place, log errors here
if(req.query.partyInvite){
try{
req.session.partyInvite = JSON.parse(utils.decrypt(req.query.partyInvite));
} catch(e){}
}
next();
};
@@ -0,0 +1,23 @@
import nconf from 'nconf';
import {
track,
trackPurchase,
mockAnalyticsService,
} from '../../libs/api-v3/analyticsService';
let service;
if (nconf.get('IS_PROD')) {
service = {
track,
trackPurchase,
};
} else {
service = mockAnalyticsService;
}
module.exports = function attachAnalytics (req, res, next) {
res.analytics = service;
next();
};
+87
View File
@@ -0,0 +1,87 @@
import {
NotAuthorized,
} from '../../libs/api-v3/errors';
import {
model as User,
} from '../../models/user';
// Strins won't be translated here because getUserLanguage has not run yet
// Authenticate a request through the x-api-user and x-api key header
// If optional is true, don't error on missing authentication
export function authWithHeaders (optional = false) {
return function authWithHeadersHandler (req, res, next) {
let userId = req.header('x-api-user');
let apiToken = req.header('x-api-key');
if (!userId || !apiToken) {
if (optional) return next();
return next(new NotAuthorized(res.t('missingAuthHeaders')));
}
return User.findOne({
_id: userId,
apiToken,
})
.exec()
.then((user) => {
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id}));
res.locals.user = user;
req.session.userId = user._id;
return next();
})
.catch(next);
};
}
// Authenticate a request through a valid session
export function authWithSession (req, res, next) {
let userId = req.session.userId;
// Always allow authentication with headers
if (!userId) {
if (!req.header('x-api-user') || !req.header('x-api-key')) {
return next(new NotAuthorized(res.t('invalidCredentials')));
} else {
return authWithHeaders()(req, res, next);
}
}
return User.findOne({
_id: userId,
})
.exec()
.then((user) => {
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
res.locals.user = user;
return next();
})
.catch(next);
}
export function authWithUrl (req, res, next) {
let userId = req.query._id;
let apiToken = req.query.apiToken;
// Always allow authentication with headers
if (!userId || !apiToken) {
if (!req.header('x-api-user') || !req.header('x-api-key')) {
return next(new NotAuthorized(res.t('missingAuthParams')));
} else {
return authWithHeaders()(req, res, next);
}
}
return User.findOne({ _id: userId, apiToken }).exec()
.then((user) => {
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
res.locals.user = user;
return next();
})
.catch(next);
}
@@ -0,0 +1,9 @@
module.exports = function corsMiddleware (req, res, next) {
res.set({
'Access-Control-Allow-Origin': req.header('origin') || '*',
'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE',
'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key',
});
if (req.method === 'OPTIONS') return res.sendStatus(200);
return next();
};
+160
View File
@@ -0,0 +1,160 @@
import _ from 'lodash';
import moment from 'moment';
import common from '../../../../common';
import * as Tasks from '../../models/task';
import Bluebird from 'bluebird';
import { model as Group } from '../../models/group';
import { model as User } from '../../models/user';
import { cron } from '../../libs/api-v3/cron';
const daysSince = common.daysSince;
module.exports = function cronMiddleware (req, res, next) {
let user = res.locals.user;
if (!user) return next(); // User might not be available when authentication is not mandatory
let analytics = res.analytics;
let now = new Date();
// If the user's timezone has changed (due to travel or daylight savings),
// cron can be triggered twice in one day, so we check for that and use
// both timezones to work out if cron should run.
// CDS = Custom Day Start time.
let timezoneOffsetFromUserPrefs = user.preferences.timezoneOffset || 0;
let timezoneOffsetAtLastCron = _.isFinite(user.preferences.timezoneOffsetAtLastCron) ? user.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs;
let timezoneOffsetFromBrowser = Number(req.header('x-user-timezoneoffset'));
timezoneOffsetFromBrowser = _.isFinite(timezoneOffsetFromBrowser) ? timezoneOffsetFromBrowser : timezoneOffsetFromUserPrefs;
// NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults
if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) {
// The user's browser has just told Habitica that the user's timezone has
// changed so store and use the new zone.
user.preferences.timezoneOffset = timezoneOffsetFromBrowser;
timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser;
}
// How many days have we missed using the user's current timezone:
let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences));
if (timezoneOffsetAtLastCron !== timezoneOffsetFromUserPrefs) {
// Since cron last ran, the user's timezone has changed.
// How many days have we missed using the old timezone:
let daysMissedNewZone = daysMissed;
let daysMissedOldZone = daysSince(user.lastCron, _.defaults({
now,
timezoneOffsetOverride: timezoneOffsetAtLastCron,
}, user.preferences));
if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) {
// The timezone change was in the unsafe direction.
// E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0).
// or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300).
// Local time changed from, for example, 03:00 to 02:00.
if (daysMissedOldZone > 0 && daysMissedNewZone > 0) {
// Both old and new timezones indicate that we SHOULD run cron, so
// it is safe to do so immediately.
daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone);
// use minimum value to be nice to user
} else if (daysMissedOldZone > 0) {
// The old timezone says that cron should run; the new timezone does not.
// This should be impossible for this direction of timezone change, but
// just in case I'm wrong...
// TODO
// console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens
} else if (daysMissedNewZone > 0) {
// The old timezone says that cron should NOT run -- i.e., cron has
// already run today, from the old timezone's point of view.
// The new timezone says that cron SHOULD run, but this is almost
// certainly incorrect.
// This happens when cron occurred at a time soon after the CDS. When
// you reinterpret that time in the new timezone, it looks like it
// was before the CDS, because local time has stepped backwards.
// To fix this, rewrite the cron time to a time that the new
// timezone interprets as being in today.
daysMissed = 0; // prevent cron running now
let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs;
// e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60
user.lastCron = moment(user.lastCron).subtract(timezoneOffsetDiff, 'minutes');
// NB: We don't change user.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran.
// From now on we can ignore the old timezone:
user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
} else {
// Both old and new timezones indicate that cron should
// NOT run.
daysMissed = 0; // prevent cron running now
}
} else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) {
daysMissed = daysMissedNewZone;
// TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff.
// There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone;
// if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared).
}
}
if (daysMissed <= 0) return next();
// Fetch active tasks (no completed todos)
Tasks.Task.find({
userId: user._id,
$or: [ // Exclude completed todos
{type: 'todo', completed: false},
{type: {$in: ['habit', 'daily', 'reward']}},
],
}).exec()
.then(tasks => {
let tasksByType = {habits: [], dailys: [], todos: [], rewards: []};
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
// Run cron
let progress = cron({user, tasksByType, now, daysMissed, analytics, timezoneOffsetFromUserPrefs});
// Clear old completed todos - 30 days for free users, 90 for subscribers
// Do not delete challenges completed todos TODO unless the task is broken?
Tasks.Task.remove({
userId: user._id,
type: 'todo',
completed: true,
dateCompleted: {
$lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(),
},
'challenge.id': {$exists: false},
}).exec();
let ranCron = user.isModified();
let quest = common.content.quests[user.party.quest.key];
if (ranCron) res.locals.wasModified = true; // TODO remove after v2 is retired
if (!ranCron) return next();
// Group.tavernBoss(user, progress);
// Save user and tasks
let toSave = [user.save()];
tasks.forEach(task => {
if (task.isModified()) toSave.push(task.save());
});
return Bluebird.all(toSave)
.then(saved => {
user = res.locals.user = saved[0];
if (!quest) return;
// If user is on a quest, roll for boss & player, or handle collections
let questType = quest.boss ? 'boss' : 'collect';
// TODO this saves user, runs db updates, loads user. Is there a better way to handle this?
return Group[`${questType}Quest`](user, progress)
.then(() => User.findById(user._id).exec()) // fetch the updated user...
.then(updatedUser => {
res.locals.user = updatedUser;
return null;
});
})
.then(() => next())
.catch(next);
});
};
@@ -0,0 +1,13 @@
import domainMiddleware from 'domain-middleware';
module.exports = function implementDomainMiddleware (server, mongoose) {
return domainMiddleware({
server: {
close () {
server.close();
mongoose.connection.close();
},
},
killTimeout: 10000,
});
};
@@ -0,0 +1,23 @@
import {
NotAuthorized,
} from '../../libs/api-v3/errors';
export function ensureAdmin (req, res, next) {
let user = res.locals.user;
if (!user.contributor.admin) {
return next(new NotAuthorized(res.t('noAdminAccess')));
}
next();
}
export function ensureSudo (req, res, next) {
let user = res.locals.user;
if (!user.contributor.sudo) {
return next(new NotAuthorized(res.t('noSudoAccess')));
}
next();
}
@@ -0,0 +1,12 @@
import nconf from 'nconf';
import {
NotFound,
} from '../../libs/api-v3/errors';
module.exports = function ensureDevelpmentMode (req, res, next) {
if (nconf.get('IS_PROD')) {
next(new NotFound());
} else {
next();
}
};
@@ -0,0 +1,86 @@
// The error handler middleware that handles all errors
// and respond to the client
import logger from '../../libs/api-v3/logger';
import {
CustomError,
BadRequest,
InternalServerError,
} from '../../libs/api-v3/errors';
import {
map,
omit,
} from 'lodash';
module.exports = function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars
logger.error(err, {
originalUrl: req.originalUrl,
headers: omit(req.headers, ['x-api-key']),
body: req.body,
});
// In case of a CustomError class, use it's data
// Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...)
// If we can't identify it, respond with a generic 500 error
let responseErr = err instanceof CustomError ? err : null;
// Handle errors created with 'http-errors' or similar that have a status/statusCode property
if (err.statusCode && typeof err.statusCode === 'number') {
responseErr = new CustomError();
responseErr.httpCode = err.statusCode;
responseErr.name = err.name;
responseErr.message = err.message;
}
// Handle errors by express-validator
if (Array.isArray(err) && err[0].param && err[0].msg) {
responseErr = new BadRequest(res.t('invalidReqParams'));
responseErr.errors = err.map((paramErr) => {
return {
message: paramErr.msg,
param: paramErr.param,
value: paramErr.value,
};
});
}
// Handle mongoose validation errors
if (err.name === 'ValidationError') {
responseErr = new BadRequest(err.message); // TODO standard message? translate?
responseErr.errors = map(err.errors, (mongooseErr) => {
return {
message: mongooseErr.message,
path: mongooseErr.path,
value: mongooseErr.value,
};
});
}
// Handle Stripe Card errors errors (can be safely shown to the users)
// https://stripe.com/docs/api/node#errors
if (err.type === 'StripeCardError') {
responseErr = new BadRequest(err.message);
}
if (!responseErr || responseErr.httpCode >= 500) {
// Try to identify the error...
// ...
// Otherwise create an InternalServerError and use it
// we don't want to leak anything, just a generic error message
// Use it also in case of identified errors but with httpCode === 500
responseErr = new InternalServerError();
}
let jsonRes = {
success: false,
error: responseErr.name,
message: responseErr.message,
};
if (responseErr.errors) {
jsonRes.errors = responseErr.errors;
}
// In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible,
// in this case we use the standard res.status(...).json(...)
return res.status(responseErr.httpCode).json(jsonRes);
};
@@ -0,0 +1,87 @@
// This module is only used to attach middlewares to the express app
import errorHandler from './errorHandler';
import bodyParser from 'body-parser';
import notFoundHandler from './notFound';
import nconf from 'nconf';
import morgan from 'morgan';
import cookieSession from 'cookie-session';
import cors from './cors';
import staticMiddleware from './static';
import domainMiddleware from './domain';
import mongoose from 'mongoose';
import compression from 'compression';
import favicon from 'serve-favicon';
import methodOverride from 'method-override';
import passport from 'passport';
import path from 'path';
import maintenanceMode from './maintenanceMode';
import {
forceSSL,
forceHabitica,
} from './redirects';
import v1 from './v1';
import v2 from './v2';
import v3 from './v3';
import responseHandler from './response';
import {
attachTranslateFunction,
} from './language';
const IS_PROD = nconf.get('IS_PROD');
const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING');
const PUBLIC_DIR = path.join(__dirname, '/../../../client');
const SESSION_SECRET = nconf.get('SESSION_SECRET');
const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14;
module.exports = function attachMiddlewares (app, server) {
app.set('view engine', 'jade');
app.set('views', `${__dirname}/../views`);
app.use(domainMiddleware(server, mongoose));
if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev'));
// add res.respond and res.t
app.use(responseHandler);
app.use(attachTranslateFunction);
app.use(compression());
app.use(favicon(`${PUBLIC_DIR}/favicon.ico`));
app.use(maintenanceMode);
app.use(cors);
app.use(forceSSL);
app.use(forceHabitica);
app.use(bodyParser.urlencoded({
extended: true, // Uses 'qs' library as old connect middleware
}));
app.use(bodyParser.json());
app.use(methodOverride());
app.use(cookieSession({
name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies
secret: SESSION_SECRET,
httpOnly: true, // so cookies are not accessible with browser JS
// TODO what about https only (secure) ?
maxAge: TWO_WEEKS,
}));
// Initialize Passport! Also use passport.session() middleware, to support
// persistent login sessions (recommended).
app.use(passport.initialize());
app.use(passport.session());
app.use('/api/v2', v2);
app.use('/api/v1', v1);
app.use(v3); // the main app, also setup top-level routes
staticMiddleware(app);
app.use(notFoundHandler);
// Error handler middleware, define as the last one.
// Used for v3 and v1, v2 will keep using its own error handler
app.use(errorHandler);
};
@@ -0,0 +1,91 @@
import { model as User } from '../../models/user';
import accepts from 'accepts';
import common from '../../../../common';
import _ from 'lodash';
import {
translations,
defaultLangCodes,
multipleVersionsLanguages,
} from '../../libs/api-v3/i18n';
const i18n = common.i18n;
function _getUniqueListOfLanguages (languages) {
let acceptableLanguages = _(languages).map((lang) => {
return lang.slice(0, 2);
}).uniq().value();
let uniqueListOfLanguages = _.intersection(acceptableLanguages, defaultLangCodes);
return uniqueListOfLanguages;
}
function _checkForApplicableLanguageVariant (originalLanguageOptions) {
let languageVariant = _.find(originalLanguageOptions, (accepted) => {
let trimmedAccepted = accepted.slice(0, 2);
return multipleVersionsLanguages[trimmedAccepted];
});
return languageVariant;
}
function _getFromBrowser (req) {
let originalLanguageOptions = accepts(req).languages();
let uniqueListOfLanguages = _getUniqueListOfLanguages(originalLanguageOptions);
let baseLanguage = (uniqueListOfLanguages[0] || '').toLowerCase();
let languageMapping = multipleVersionsLanguages[baseLanguage];
if (languageMapping) {
let languageVariant = _checkForApplicableLanguageVariant(originalLanguageOptions);
if (languageVariant) {
languageVariant = languageVariant.toLowerCase();
} else {
return 'en';
}
return languageMapping[languageVariant] || baseLanguage;
} else {
return baseLanguage || 'en';
}
}
function _getFromUser (user, req) {
let preferredLang = user && user.preferences && user.preferences.language;
let lang = translations[preferredLang] ? preferredLang : _getFromBrowser(req);
return lang;
}
export function attachTranslateFunction (req, res, next) {
res.t = function reqTranslation () {
return i18n.t(...arguments, req.language);
};
next();
}
export function getUserLanguage (req, res, next) {
if (req.query.lang) { // In case the language is specified in the request url, use it
req.language = translations[req.query.lang] ? req.query.lang : 'en';
return next();
} else if (req.locals && req.locals.user) { // If the request is authenticated, use the user's preferred language
req.language = _getFromUser(req.locals.user, req);
return next();
} else if (req.session && req.session.userId) { // Same thing if the user has a valid session
return User.findOne({
_id: req.session.userId,
}, 'preferences.language')
.lean()
.exec()
.then((user) => {
req.language = _getFromUser(user, req);
return next();
})
.catch(next);
} else { // Otherwise get from browser
req.language = _getFromUser(null, req);
return next();
}
}
@@ -0,0 +1,61 @@
import nconf from 'nconf';
import _ from 'lodash';
import shared from '../../../../common';
import * as i18n from '../../libs/api-v3/i18n';
import {
getBuildUrl,
getManifestFiles,
} from '../../libs/api-v3/buildManifest';
import forceRefresh from './../forceRefresh';
import { tavernQuest } from '../../models/group';
import { mods } from '../../models/user';
// To avoid stringifying more data then we need,
// items from `env` used on the client will have to be specified in this array
const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations',
'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID',
'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY',
'worldDmg', 'mods', 'IS_MOBILE'];
let env = {
getManifestFiles,
getBuildUrl,
_,
clientVars: CLIENT_VARS,
mods,
Content: shared.content,
siteVersion: forceRefresh.siteVersion,
availableLanguages: i18n.availableLanguages,
AMAZON_PAYMENTS: {
SELLER_ID: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
CLIENT_ID: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'),
},
};
'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(key => {
env[key] = nconf.get(key);
});
module.exports = function locals (req, res, next) {
let language = _.find(i18n.availableLanguages, {code: req.language});
let 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;
res.locals.habitrpg = _.assign(env, {
IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')),
language,
isStaticPage,
translations: i18n.translations[language.code],
t (...args) { // stringName and vars are the allowed parameters
args.push(language.code);
return shared.i18n.t(...args);
},
// Defined here and not outside of the middleware because tavernQuest might be an
// empty object until the query to fetch it finishes
worldDmg: tavernQuest && tavernQuest.extra && tavernQuest.extra.worldDmg || {},
});
next();
};
@@ -0,0 +1,31 @@
import { getUserLanguage } from './language';
import nconf from 'nconf';
const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE');
module.exports = function maintenanceMode (req, res, next) {
if (MAINTENANCE_MODE !== 'true') return next();
getUserLanguage(req, res, (err) => {
if (err) return next(err);
let pageVariables = {
maintenanceStart: nconf.get('MAINTENANCE_START'),
maintenanceEnd: nconf.get('MAINTENANCE_END'),
translation: res.t,
};
if (req.headers && req.headers.accept && req.headers.accept.indexOf('text/html') !== -1) {
if (req.path === '/views/static/maintenance-info') {
return res.status(503).render('../../../views/static/maintenance-info', pageVariables);
} else {
return res.status(503).render('../../../views/static/maintenance', pageVariables);
}
} else {
return res.status(503).send({
error: 'Maintenance',
message: 'Server offline for maintenance.',
});
}
});
};
@@ -0,0 +1,7 @@
import {
NotFound,
} from '../../libs/api-v3/errors';
module.exports = function NotFoundMiddleware (req, res, next) {
next(new NotFound());
};
@@ -0,0 +1,43 @@
import nconf from 'nconf';
const IS_PROD = nconf.get('IS_PROD');
const IGNORE_REDIRECT = nconf.get('IGNORE_REDIRECT');
const BASE_URL = nconf.get('BASE_URL');
function isHTTP (req) {
return ( // eslint-disable-line no-extra-parens
req.header('x-forwarded-proto') &&
req.header('x-forwarded-proto') !== 'https' &&
IS_PROD &&
BASE_URL.indexOf('https') === 0
);
}
function isProxied (req) {
return ( // eslint-disable-line no-extra-parens
req.header('x-habitica-lb') &&
req.header('x-habitica-lb') === 'Yes'
);
}
export function forceSSL (req, res, next) {
if (isHTTP(req) && !isProxied(req)) {
return res.redirect(BASE_URL + req.originalUrl);
}
next();
}
// Redirect to habitica for non-api urls
function nonApiUrl (req) {
return req.originalUrl.search(/\/api\//) === -1;
}
export function forceHabitica (req, res, next) {
if (IS_PROD && !IGNORE_REDIRECT && !isProxied(req) && nonApiUrl(req)) {
return res.redirect(301, BASE_URL + req.url);
}
next();
}
@@ -0,0 +1,25 @@
module.exports = function responseHandler (req, res, next) {
// Only used for successful responses
res.respond = function respond (status = 200, data = {}, message) {
let user = res.locals && res.locals.user;
let response = {
success: status < 400,
data,
};
if (message) response.message = message;
// When userV=Number (user version) query parameter is passed and a user is logged in,
// sends back the current user._v in the response so that the client
// can verify if it's the most up to date data.
// Considered part of the private API for now and not officially supported
if (user && req.query.userV) {
response.userV = user._v;
}
res.status(status).json(response);
};
next();
};
@@ -0,0 +1,5 @@
// TODO test this middleware
module.exports = function setupBodyMiddleware (req, res, next) {
req.body = req.body || {};
next();
};
@@ -0,0 +1,18 @@
import express from 'express';
import nconf from 'nconf';
import path from 'path';
const IS_PROD = nconf.get('IS_PROD');
const MAX_AGE = IS_PROD ? 31536000000 : 0;
const PUBLIC_DIR = path.join(__dirname, '/../../../client');
const BUILD_DIR = path.join(__dirname, '/../../../build');
module.exports = function staticMiddleware (expressApp) {
// TODO move all static files to a single location (one for public and one for build)
expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE }));
expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE }));
expressApp.use('/common/audio', express.static(`${PUBLIC_DIR}/../../common/audio`, { maxAge: MAX_AGE }));
expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE }));
expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE }));
expressApp.use(express.static(PUBLIC_DIR));
};
+19
View File
@@ -0,0 +1,19 @@
// API v1 middlewares and routes
// DEPRECATED AND INACTIVE
import express from 'express';
import nconf from 'nconf';
import {
NotFound,
} from '../../libs/api-v3/errors';
const router = express.Router(); // eslint-disable-line babel/new-cap
const BASE_URL = nconf.get('BASE_URL');
router.all('*', function deprecatedV1 (req, res, next) {
let error = new NotFound(`API v1 is no longer supported, please use API v3 instead (${BASE_URL}/static/api).`);
return next(error);
});
module.exports = router;
+27
View File
@@ -0,0 +1,27 @@
// DEPRECATED BUT STILL ACTIVE
// import path from 'path';
import swagger from 'swagger-node-express';
// import shared from '../../../../common';
import express from 'express';
import analytics from './analytics';
import responseHandler from './response';
const v2app = express();
// re-set the view options because they are not inherited from the top level app
v2app.set('view engine', 'jade');
v2app.set('views', `${__dirname}/../../../views`);
v2app.use(analytics);
v2app.use(responseHandler);
// Custom Directives
v2app.use('/', require('../../routes/api-v2/auth'));
require('../../routes/api-v2/swagger')(swagger, v2app);
v2app.use(require('../api-v2/errorHandler'));
module.exports = v2app;
+30
View File
@@ -0,0 +1,30 @@
import express from 'express';
import expressValidator from 'express-validator';
import analytics from './analytics';
import setupBody from './setupBody';
import routes from '../../libs/api-v3/routes';
import path from 'path';
const API_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/');
const TOP_LEVEL_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/top-level/');
const v3app = express();
// re-set the view options because they are not inherited from the top level app
v3app.set('view engine', 'jade');
v3app.set('views', `${__dirname}/../../../views`);
v3app.use(expressValidator());
v3app.use(analytics);
v3app.use(setupBody);
const topLevelRouter = express.Router(); // eslint-disable-line babel/new-cap
routes.walkControllers(topLevelRouter, TOP_LEVEL_CONTROLLERS_PATH);
v3app.use('/', topLevelRouter);
const v3Router = express.Router(); // eslint-disable-line babel/new-cap
routes.walkControllers(v3Router, API_CONTROLLERS_PATH);
v3app.use('/api/v3', v3Router);
module.exports = v3app;
+26
View File
@@ -0,0 +1,26 @@
var nconf = require('nconf');
var limiter = require('connect-ratelimit');
var IS_PROD = nconf.get('NODE_ENV') === 'production';
// TODO since Habitica runs on many different servers this module is pretty useless
// as it will only block requests that go to the same server but anyway we should probably have a rate limiter in place
module.exports = function(app) {
// disable the rate limiter middleware
if (/*!IS_PROD || */true) return;
app.use(limiter({
end:false,
categories:{
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.status(429).json({err:'Rate limit exceeded'});
next();
});
};
@@ -0,0 +1,11 @@
// TODO do we need this module anymore in v3? No
module.exports.siteVersion = 1;
module.exports.middleware = function(req, res, next){
if(req.query.siteVersion && req.query.siteVersion != module.exports.siteVersion){
return res.status(400).json({needRefresh: true});
}
return next();
};
+426
View File
@@ -0,0 +1,426 @@
import mongoose from 'mongoose';
import Bluebird from 'bluebird';
import validator from 'validator';
import baseModel from '../libs/api-v3/baseModel';
import _ from 'lodash';
import * as Tasks from './task';
import { model as User } from './user';
import {
model as Group,
TAVERN_ID,
} from './group';
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
import shared from '../../../common';
import { sendTxn as txnEmail } from '../libs/api-v3/email';
import sendPushNotification from '../libs/api-v3/pushNotifications';
import cwait from 'cwait';
let Schema = mongoose.Schema;
let schema = new Schema({
name: {type: String, required: true},
shortName: {type: String, required: true, minlength: 3},
description: String,
official: {type: Boolean, default: false},
tasksOrder: {
habits: [{type: String, ref: 'Task'}],
dailys: [{type: String, ref: 'Task'}],
todos: [{type: String, ref: 'Task'}],
rewards: [{type: String, ref: 'Task'}],
},
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
memberCount: {type: Number, default: 1},
prize: {type: Number, default: 0, min: 0},
}, {
strict: true,
minimize: false, // So empty objects are returned
});
schema.plugin(baseModel, {
noSet: ['_id', 'memberCount', 'tasksOrder'],
timestamps: true,
});
// A list of additional fields that cannot be updated (but can be set on creation)
let noUpdate = ['group', 'official', 'shortName', 'prize'];
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
return this.sanitize(updateObj, noUpdate);
};
// Returns true if user is a member of the challenge
schema.methods.isMember = function isChallengeMember (user) {
return user.challenges.indexOf(this._id) !== -1;
};
// Returns true if the user can modify (close, selectWinner, ...) the challenge
schema.methods.canModify = function canModifyChallenge (user) {
return user.contributor.admin || this.leader === user._id;
};
// Returns true if user has access to the challenge (can join)
schema.methods.hasAccess = function hasAccessToChallenge (user, group) {
if (group.type === 'guild' && group.privacy === 'public') return true;
return user.getGroups().indexOf(this.group) !== -1;
};
// Returns true if user can view the challenge
// Different from hasAccess because you can see challenges of groups you've been removed from if you're partecipating in them
schema.methods.canView = function canViewChallenge (user, group) {
if (this.isMember(user)) return true;
return this.hasAccess(user, group);
};
// Takes a Task document and return a plain object of attributes that can be synced to the user
function _syncableAttrs (task) {
let t = task.toObject(); // lodash doesn't seem to like _.omit on Document
// only sync/compare important attrs
let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt'];
if (t.type !== 'reward') omitAttrs.push('value');
return _.omit(t, omitAttrs);
}
// Sync challenge to user, including tasks and tags.
// Used when user joins the challenge or to force sync.
schema.methods.syncToUser = async function syncChallengeToUser (user) {
let challenge = this;
challenge.shortName = challenge.shortName || challenge.name;
// Add challenge to user.challenges
if (!_.contains(user.challenges, challenge._id)) user.challenges.push(challenge._id);
// Sync tags
let userTags = user.tags;
let i = _.findIndex(userTags, {id: challenge._id});
if (i !== -1) {
if (userTags[i].name !== challenge.shortName) {
// update the name - it's been changed since
userTags[i].name = challenge.shortName;
}
} else {
userTags.push({
id: challenge._id,
name: challenge.shortName,
challenge: true,
});
}
let [challengeTasks, userTasks] = await Bluebird.all([
// Find original challenge tasks
Tasks.Task.find({
userId: {$exists: false},
'challenge.id': challenge._id,
}).exec(),
// Find user's tasks linked to this challenge
Tasks.Task.find({
userId: user._id,
'challenge.id': challenge._id,
}).exec(),
]);
let toSave = []; // An array of things to save
challengeTasks.forEach(chalTask => {
let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id);
if (!matchingTask) { // If the task is new, create it
matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask)));
matchingTask.challenge = {taskId: chalTask._id, id: challenge._id};
matchingTask.userId = user._id;
user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id);
} else {
_.merge(matchingTask, _syncableAttrs(chalTask));
// Make sure the task is in user.tasksOrder
let orderList = user.tasksOrder[`${chalTask.type}s`];
if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
}
if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided
if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing
toSave.push(matchingTask.save());
});
// Flag deleted tasks as "broken"
userTasks.forEach(userTask => {
if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) {
userTask.challenge.broken = 'TASK_DELETED';
toSave.push(userTask.save());
}
});
toSave.push(user.save());
return Bluebird.all(toSave);
};
async function _fetchMembersIds (challengeId) {
return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id);
}
async function _addTaskFn (challenge, tasks, memberId) {
let updateTasksOrderQ = {$push: {}};
let toSave = [];
tasks.forEach(chalTask => {
let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask)));
userTask.challenge = {taskId: chalTask._id, id: challenge._id};
userTask.userId = memberId;
let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`];
if (!tasksOrderList) {
updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = {
$position: 0, // unshift
$each: [userTask._id],
};
} else {
tasksOrderList.$each.unshift(userTask._id);
}
toSave.push(userTask.save({
validateBeforeSave: false, // no user data supplied
}));
});
// Update the user
toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec());
return await Bluebird.all(toSave);
}
// Add a new task to challenge members
schema.methods.addTasks = async function challengeAddTasks (tasks) {
let challenge = this;
let membersIds = await _fetchMembersIds(challenge._id);
let queue = new cwait.TaskQueue(Bluebird, 5); // process only 5 users concurrently
await Bluebird.map(membersIds, queue.wrap((memberId) => {
return _addTaskFn(challenge, tasks, memberId);
}));
};
// Sync updated task to challenge members
schema.methods.updateTask = async function challengeUpdateTask (task) {
let challenge = this;
let updateCmd = {$set: {}};
let syncableAttrs = _syncableAttrs(task);
for (let key in syncableAttrs) {
updateCmd.$set[key] = syncableAttrs[key];
}
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
await Tasks.Task.update({
userId: {$exists: true},
'challenge.id': challenge.id,
'challenge.taskId': task._id,
}, updateCmd, {multi: true}).exec();
};
// Remove a task from challenge members
schema.methods.removeTask = async function challengeRemoveTask (task) {
let challenge = this;
// Set the task as broken
await Tasks.Task.update({
userId: {$exists: true},
'challenge.id': challenge.id,
'challenge.taskId': task._id,
}, {
$set: {'challenge.broken': 'TASK_DELETED'},
}, {multi: true}).exec();
};
// Unlink challenges tasks (and the challenge itself) from user
schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) {
let challengeId = this._id;
let findQuery = {
userId: user._id,
'challenge.id': challengeId,
};
removeFromArray(user.challenges, challengeId);
if (keep === 'keep-all') {
await Tasks.Task.update(findQuery, {
$set: {challenge: {}},
}, {multi: true}).exec();
await user.save();
} else { // keep = 'remove-all'
let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec();
let taskPromises = tasks.map(task => {
// Remove task from user.tasksOrder and delete them
if (task.type !== 'todo' || !task.completed) {
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
}
return task.remove();
});
user.markModified('tasksOrder');
taskPromises.push(user.save());
return Bluebird.all(taskPromises);
}
};
// TODO everything here should be moved to a worker
// actually even for a worker it's probably just too big and will kill mongo, figure out something else
schema.methods.closeChal = async function closeChal (broken = {}) {
let challenge = this;
let winner = broken.winner;
let brokenReason = broken.broken;
// Delete the challenge
await this.model('Challenge').remove({_id: challenge._id}).exec();
// Refund the leader if the challenge is closed and the group not the tavern
if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') {
await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec();
}
// Update the challengeCount on the group
await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec();
// Award prize to winner and notify
if (winner) {
winner.achievements.challenges.push(challenge.name);
winner.balance += challenge.prize / 4;
let savedWinner = await winner.save();
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
txnEmail(savedWinner, 'won-challenge', [
{name: 'CHALLENGE_NAME', content: challenge.name},
]);
}
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name);
}
// Run some operations in the background withouth blocking the thread
let backgroundTasks = [
// And it's tasks
Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(),
// Set the challenge tag to non-challenge status and remove the challenge from the user's challenges
User.update({
challenges: challenge._id,
'tags._id': challenge._id,
}, {
$set: {'tags.$.challenge': false},
$pull: {challenges: challenge._id},
}, {multi: true}).exec(),
// Break users' tasks
Tasks.Task.update({
'challenge.id': challenge._id,
}, {
$set: {
'challenge.broken': brokenReason,
'challenge.winner': winner && winner.profile.name,
},
}, {multi: true}).exec(),
];
Bluebird.all(backgroundTasks);
};
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model)
// These will be removed once API v2 is discontinued
// Get all the tasks belonging to a challenge,
schema.methods.getTasks = function getChallengeTasks () {
let args = Array.from(arguments);
let cb;
let type;
if (args.length === 1) {
cb = args[0];
} else if (args.length > 1) {
type = args[0];
cb = args[1];
} else {
cb = function noop () {};
}
let query = {
userId: {
$exists: false,
},
'challenge.id': this._id,
};
if (type) query.type = type;
return Tasks.Task.find(query, cb); // so we can use it as a promise
};
// Given challenge and an array of tasks and one of members return an API compatible challenge + tasks obj + members
schema.methods.addToChallenge = function addToChallenge (tasks, members) {
let obj = this.toJSON();
obj.members = members;
let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it
obj.habits = [];
obj.dailys = [];
obj.todos = [];
obj.rewards = [];
obj.tasksOrder = undefined;
let unordered = [];
tasks.forEach((task) => {
// We want to push the task at the same position where it's stored in tasksOrder
let pos = tasksOrder[`${task.type}s`].indexOf(task._id);
if (pos === -1) { // Should never happen, it means the lists got out of sync
unordered.push(task.toJSONV2());
} else {
obj[`${task.type}s`][pos] = task.toJSONV2();
}
});
// Reconcile unordered items
unordered.forEach((task) => {
obj[`${task.type}s`].push(task);
});
// Remove null values that can be created when inserting tasks at an index > length
['habits', 'dailys', 'rewards', 'todos'].forEach((type) => {
obj[type] = _.compact(obj[type]);
});
return obj;
};
// Return the data maintaining backward compatibility
schema.methods.getTransformedData = function getTransformedData (options) {
let self = this;
let cb = options.cb;
let populateMembers = options.populateMembers;
let queryMembers = {
challenges: self._id,
};
let selectDataMembers = '_id';
if (populateMembers) {
selectDataMembers += ` ${populateMembers}`;
}
let membersQuery = User.find(queryMembers).select(selectDataMembers);
if (options.limitPopulation) membersQuery.limit(15);
Bluebird.all([
membersQuery.exec(),
self.getTasks(),
])
.then((results) => {
cb(null, self.addToChallenge(results[1], results[0]));
})
.catch(cb);
};
// END of API v2 methods
export let model = mongoose.model('Challenge', schema);
+57
View File
@@ -0,0 +1,57 @@
/* eslint-disable camelcase */
import mongoose from 'mongoose';
import _ from 'lodash';
import shared from '../../../common';
import couponCode from 'coupon-code';
import baseModel from '../libs/api-v3/baseModel';
import {
BadRequest,
NotAuthorized,
} from '../libs/api-v3/errors';
export let schema = new mongoose.Schema({
_id: {type: String, default: couponCode.generate},
event: {type: String, enum: ['wondercon', 'google_6mo']},
user: {type: String, ref: 'User'},
}, {
strict: true,
minimize: false, // So empty objects are returned
});
schema.plugin(baseModel, {
timestamps: true,
_id: false,
});
schema.statics.generate = async function generateCoupons (event, count = 1) {
let coupons = _.times(count, () => {
return {event};
});
return await this.create(coupons);
};
schema.statics.apply = async function applyCoupon (user, req, code) {
let coupon = await this.findById(couponCode.validate(code)).exec();
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon', req.language));
if (coupon.user) throw new NotAuthorized(shared.i18n.t('couponUsed', req.language));
if (coupon.event === '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'};
}
await user.save();
coupon.user = user._id;
await coupon.save();
};
module.exports.schema = schema;
export let model = mongoose.model('Coupon', schema);
@@ -0,0 +1,24 @@
import mongoose from 'mongoose';
import validator from 'validator';
import baseModel from '../libs/api-v3/baseModel';
// A collection used to store mailing list unsubscription for non registered email addresses
export let schema = new mongoose.Schema({
email: {
type: String,
required: true,
trim: true,
lowercase: true,
validator: [validator.isEmail, 'Invalid email.'],
},
}, {
strict: true,
minimize: false, // So empty objects are returned
});
schema.plugin(baseModel, {
noSet: ['_id'],
timestamps: true,
});
export let model = mongoose.model('EmailUnsubscription', schema);
+760
View File
@@ -0,0 +1,760 @@
import mongoose from 'mongoose';
import {
model as User,
nameFields,
} from './user';
import shared from '../../../common';
import _ from 'lodash';
import { model as Challenge} from './challenge';
import validator from 'validator';
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
import {
InternalServerError,
BadRequest,
} from '../libs/api-v3/errors';
import * as firebase from '../libs/api-v2/firebase';
import baseModel from '../libs/api-v3/baseModel';
import { sendTxn as sendTxnEmail } from '../libs/api-v3/email';
import Bluebird from 'bluebird';
import nconf from 'nconf';
import sendPushNotification from '../libs/api-v3/pushNotifications';
const questScrolls = shared.content.quests;
const Schema = mongoose.Schema;
export const INVITES_LIMIT = 100;
export const TAVERN_ID = shared.TAVERN_ID;
// NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API
// changes made directly to the db will cause Firebase to get out of sync
export let schema = new Schema({
name: {type: String, required: true},
description: String,
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
type: {type: String, enum: ['guild', 'party'], required: true},
privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true},
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, required: true},
// invites: {type: Boolean, default: false, required: true},
},
memberCount: {type: Number, default: 1},
challengeCount: {type: Number, default: 0},
balance: {type: Number, default: 0},
logo: String,
leaderMessage: String,
quest: {
key: String,
active: {type: Boolean, default: false},
leader: {type: String, ref: 'User'},
progress: {
hp: Number,
collect: {type: Schema.Types.Mixed, default: () => {
return {};
}}, // {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: {type: Schema.Types.Mixed, default: () => {
return {};
}},
extra: {type: Schema.Types.Mixed, default: () => {
return {};
}},
},
}, {
strict: true,
minimize: false, // So empty objects are returned
});
schema.plugin(baseModel, {
noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'],
});
// A list of additional fields that cannot be updated (but can be set on creation)
let noUpdate = ['privacy', 'type'];
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
return this.sanitize(updateObj, noUpdate);
};
// Basic fields to fetch for populating a group info
export let basicFields = 'name type privacy';
schema.pre('remove', true, async function preRemoveGroup (next, done) {
next();
try {
await this.removeGroupInvitations();
done();
} catch (err) {
done(err);
}
});
schema.post('remove', function postRemoveGroup (group) {
firebase.deleteGroup(group._id);
});
schema.statics.getGroup = async function getGroup (options = {}) {
let {user, groupId, fields, optionalMembership = false, populateLeader = false, requireMembership = false} = options;
let query;
let isUserParty = groupId === 'party' || user.party._id === groupId;
let isUserGuild = user.guilds.indexOf(groupId) !== -1;
let isTavern = ['habitrpg', TAVERN_ID].indexOf(groupId) !== -1;
// When requireMembership is true check that user is member even in public guild
if (requireMembership && !isUserParty && !isUserGuild && !isTavern) {
return null;
}
// When optionalMembership is true it's not required for the user to be a member of the group
if (isUserParty) {
query = {type: 'party', _id: user.party._id};
} else if (isTavern) {
query = {_id: TAVERN_ID};
} else if (optionalMembership === true) {
query = {_id: groupId};
} else if (isUserGuild) {
query = {type: 'guild', _id: groupId};
} else {
query = {type: 'guild', privacy: 'public', _id: groupId};
}
let mQuery = this.findOne(query);
if (fields) mQuery.select(fields);
if (populateLeader === true) mQuery.populate('leader', nameFields);
let group = await mQuery.exec();
return group;
};
export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'publicGuilds', 'tavern'];
schema.statics.getGroups = async function getGroups (options = {}) {
let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options;
let queries = [];
// Throw error if an invalid type is supplied
let areValidTypes = types.every(type => VALID_QUERY_TYPES.indexOf(type) !== -1);
if (!areValidTypes) throw new BadRequest(shared.i18n.t('groupTypesRequired'));
types.forEach(type => {
switch (type) {
case 'party': {
queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader}));
break;
}
case 'guilds': {
let userGuildsQuery = this.find({
type: 'guild',
_id: {$in: user.guilds},
}).select(groupFields);
if (populateLeader === true) userGuildsQuery.populate('leader', nameFields);
userGuildsQuery.sort(sort).exec();
queries.push(userGuildsQuery);
break;
}
case 'privateGuilds': {
let privateGuildsQuery = this.find({
type: 'guild',
privacy: 'private',
_id: {$in: user.guilds},
}).select(groupFields);
if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields);
privateGuildsQuery.sort(sort).exec();
queries.push(privateGuildsQuery);
break;
}
// NOTE: when returning publicGuilds we use `.lean()` so all mongoose methods won't be available.
// Docs are going to be plain javascript objects
case 'publicGuilds': {
let publicGuildsQuery = this.find({
type: 'guild',
privacy: 'public',
}).select(groupFields);
if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields);
publicGuildsQuery.sort(sort).lean().exec();
queries.push(publicGuildsQuery);
break;
}
case 'tavern': {
if (types.indexOf('publicGuilds') === -1) {
queries.push(this.getGroup({user, groupId: TAVERN_ID, fields: groupFields}));
}
break;
}
}
});
let groupsArray = _.reduce(await Bluebird.all(queries), (previousValue, currentValue) => {
if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array
return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue
}, []);
return groupsArray;
};
// When converting to json remove chat messages with more than 1 flag and remove all flags info
// unless the user is an admin
// Not putting into toJSON because there we can't access user
schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
let toJSON = group.toJSON();
if (!user.contributor.admin) {
_.remove(toJSON.chat, chatMsg => {
chatMsg.flags = {};
return chatMsg.flagCount >= 2;
});
}
return toJSON;
};
schema.methods.removeGroupInvitations = async function removeGroupInvitations () {
let group = this;
let usersToRemoveInvitationsFrom = await User.find({
[`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id,
}).exec();
let userUpdates = usersToRemoveInvitationsFrom.map(user => {
if (group.type === 'party') {
user.invitations.party = {};
this.markModified('invitations.party');
} else {
removeFromArray(user.invitations.guilds, { id: group._id });
}
return user.save();
});
return Bluebird.all(userUpdates);
};
// Return true if user is a member of the group
schema.methods.isMember = function isGroupMember (user) {
if (this._id === TAVERN_ID) {
return true; // everyone is considered part of the tavern
} else if (this.type === 'party') {
return user.party._id === this._id ? true : false;
} else { // guilds
return user.guilds.indexOf(this._id) !== -1;
}
};
export function chatDefaults (msg, user) {
let message = {
id: shared.uuid(),
text: msg,
timestamp: Number(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;
}
const NO_CHAT_NOTIFICATIONS = [TAVERN_ID];
schema.methods.sendChat = function sendChat (message, user) {
this.chat.unshift(chatDefaults(message, user));
this.chat.splice(200);
// Kick off chat notifications in the background.
let lastSeenUpdate = {$set: {}, $inc: {_v: 1}};
lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true};
// do not send notifications for guilds with more than 5000 users and for the tavern
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > 5000) {
// 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 {
let query = {};
if (this.type === 'party') {
query['party._id'] = this._id;
} else {
query.guilds = this._id;
}
query._id = { $ne: user ? user._id : ''};
User.update(query, lastSeenUpdate, {multi: true}).exec();
}
};
schema.methods.startQuest = async function startQuest (user) {
// not using i18n strings because these errors are meant for devs who forgot to pass some parameters
if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method');
if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest');
if (this.quest.active) throw new InternalServerError('Quest is already active');
let userIsParticipating = this.quest.members[user._id];
let quest = questScrolls[this.quest.key];
let collected = {};
if (quest.collect) {
collected = _.transform(quest.collect, (result, n, itemToCollect) => {
result[itemToCollect] = 0;
});
}
this.markModified('quest');
this.quest.active = true;
if (quest.boss) {
this.quest.progress.hp = quest.boss.hp;
if (quest.boss.rage) this.quest.progress.rage = 0;
} else if (quest.collect) {
this.quest.progress.collect = collected;
}
// Changes quest.members to only include participating members
// TODO: is that important? What does it matter if the non-participating members
// are still on the object?
// TODO: is it important to run clean quest progress on non-members like we did in v2?
this.quest.members = _.pick(this.quest.members, _.identity);
let nonUserQuestMembers = _.keys(this.quest.members);
removeFromArray(nonUserQuestMembers, user._id);
if (userIsParticipating) {
user.party.quest.key = this.quest.key;
user.party.quest.progress.down = 0;
user.party.quest.progress.collect = collected;
user.party.quest.completed = null;
user.markModified('party.quest');
}
// Remove the quest from the quest leader items (if they are the current user)
if (this.quest.leader === user._id) {
user.items.quests[this.quest.key] -= 1;
user.markModified('items.quests');
} else { // another user is starting the quest, update the leader separately
await User.update({_id: this.quest.leader}, {
$inc: {
[`items.quests.${this.quest.key}`]: -1,
},
}).exec();
}
// update the remaining users
await User.update({
_id: { $in: nonUserQuestMembers },
}, {
$set: {
'party.quest.key': this.quest.key,
'party.quest.progress.down': 0,
'party.quest.progress.collect': collected,
'party.quest.completed': null,
},
}, { multi: true }).exec();
// send notifications in the background without blocking
User.find(
{ _id: { $in: nonUserQuestMembers } },
'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name'
).exec().then((membersToNotify) => {
let membersToEmail = _.filter(membersToNotify, (member) => {
// send push notifications and filter users that disabled emails
sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`);
return member.preferences.emailNotifications.questStarted !== false &&
member._id !== user._id;
});
sendTxnEmail(membersToEmail, 'quest-started', [
{ name: 'PARTY_URL', content: '/#/options/groups/party' },
]);
});
};
// return a clean object for user.quest
function _cleanQuestProgress (merge) {
let clean = {
key: null,
progress: {
up: 0,
down: 0,
collect: {},
},
completed: null,
RSVPNeeded: false,
};
if (merge) {
_.merge(clean, _.omit(merge, 'progress'));
if (merge.progress) _.merge(clean.progress, merge.progress);
}
return clean;
}
schema.statics.cleanQuestProgress = _cleanQuestProgress;
// returns a clean object for group.quest
schema.statics.cleanGroupQuest = function cleanGroupQuest () {
return {
key: null,
active: false,
leader: null,
progress: {
collect: {},
},
members: {},
};
};
// Participants: Grant rewards & achievements, finish quest
// Returns the promise from update().exec()
schema.methods.finishQuest = function finishQuest (quest) {
let questK = quest.key;
let updates = {$inc: {}, $set: {}};
updates.$inc[`achievements.quests.${questK}`] = 1;
updates.$inc['stats.gp'] = Number(quest.drop.gp);
updates.$inc['stats.exp'] = Number(quest.drop.exp);
updates.$inc._v = 1;
if (this._id === TAVERN_ID) {
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, (item) => {
let 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;
}
}
});
let q = this._id === TAVERN_ID ? {} : {_id: {$in: _.keys(this.quest.members)}};
this.quest = {};
this.markModified('quest');
return User.update(q, updates, {multi: true}).exec();
};
function _isOnQuest (user, progress, group) {
return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true;
}
// Returns a promise
schema.statics.collectQuest = async function collectQuest (user, progress) {
let group = await this.getGroup({user, groupId: 'party'});
if (!_isOnQuest(user, progress, group)) return;
let quest = shared.content.quests[group.quest.key];
_.each(progress.collect, (v, k) => {
group.quest.progress.collect[k] += v;
});
let foundText = _.reduce(progress.collect, (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, (v, k) => {
return group.quest.progress.collect[k] < v.count;
})) return group.save();
await group.finishQuest(quest);
group.sendChat('`All items found! Party has received their rewards.`');
return group.save();
};
schema.statics.bossQuest = async function bossQuest (user, progress) {
let group = await this.getGroup({user, groupId: 'party'});
if (!_isOnQuest(user, progress, group)) return;
let quest = shared.content.quests[group.quest.key];
if (!progress || !quest) return; // TODO why is this ever happening, progress should be defined at this point, log?
let down = progress.down * quest.boss.str; // multiply by boss strength
group.quest.progress.hp -= progress.up;
// TODO Create a party preferred language option so emits like this can be localized. Suggestion: Always display the English version too. Or, if English is not displayed to the players, at least include it in a new field in the chat object that's visible in the database - essential for admins when troubleshooting quests!
let playerAttack = `${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.`;
let bossAttack = nconf.get('CRON_SAFE_MODE') === 'true' ? `${quest.boss.name('en')} did not attack the party because it was asleep while maintenance was happening.` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`;
group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``);
// 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;
// TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage
if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing;
if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp;
}
}
// Everyone takes damage
await User.update({
_id: {$in: _.keys(group.quest.members)},
}, {
$inc: {'stats.hp': down, _v: 1},
}, {multi: true}).exec();
// Apply changes the currently cronning user locally so we don't have to reload it to get the updated state
// TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167
// must be notModified or otherwise could overwrite future changes: if the user is saved it'll save
// the modified user.stats.hp but that must not happen as the hp value has already been updated by the User.update above
// if (down) user.stats.hp += down;
// 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
await group.finishQuest(shared.content.quests[group.quest.key]);
return group.save();
}
return group.save();
};
// to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
// we export an empty object that is then populated with the query-returned data
export let tavernQuest = {};
let tavernQ = {_id: TAVERN_ID, 'quest.key': {$ne: null}};
// we use process.nextTick because at this point the model is not yet available
process.nextTick(() => {
model // eslint-disable-line no-use-before-define
.findOne(tavernQ).exec()
.then(tavern => {
if (!tavern) return; // No tavern quest
// Using _assign so we don't lose the reference to the exported tavernQuest
_.assign(tavernQuest, tavern.quest.toObject());
})
.catch(err => {
throw err;
});
});
// returns a promise
schema.statics.tavernBoss = async function tavernBoss (user, progress) {
if (!progress) return;
// hack: prevent crazy damage to world boss
let dmg = Math.min(900, Math.abs(progress.up || 0));
let rage = -Math.min(900, Math.abs(progress.down || 0));
let tavern = await this.findOne(tavernQ).exec();
if (!(tavern && tavern.quest && tavern.quest.key)) return;
let quest = shared.content.quests[tavern.quest.key];
if (tavern.quest.progress.hp <= 0) {
tavern.sendChat(quest.completionChat('en'));
await tavern.finishQuest(quest);
_.assign(tavernQuest, {extra: null});
return tavern.save();
} 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 = {};
let wd = tavern.quest.extra.worldDmg;
// Burnout attacks Ian, Seasonal Sorceress, tavern
// Be-Wilder attacks Alex, Matt, Bailey
let scene = wd.market ? wd.stables ? wd.bailey ? false : 'bailey' : 'stables' : 'market'; // eslint-disable-line no-nested-ternary
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;
if (quest.boss.rage.healing) {
tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp;
}
}
}
if (quest.boss.desperation && 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');
}
_.assign(tavernQuest, tavern.quest.toObject());
return tavern.save();
}
};
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
let group = this;
let challenges = await Challenge.find({
_id: {$in: user.challenges},
group: group._id,
});
let challengesToRemoveUserFrom = challenges.map(chal => {
return chal.unlinkTasks(user, keep);
});
await Bluebird.all(challengesToRemoveUserFrom);
let promises = [];
// remove the group from the user's groups
if (group.type === 'guild') {
promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id}}).exec());
} else {
promises.push(User.update({_id: user._id}, {$set: {party: {}}}).exec());
}
// If user is the last one in group and group is private, delete it
if (group.memberCount <= 1 && group.privacy === 'private') {
promises.push(group.remove());
} else { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
let update = {
$inc: {memberCount: -1},
};
if (group.leader === user._id) {
let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id};
query._id = {$ne: user._id};
let seniorMember = await User.findOne(query).select('_id').exec();
// could be missing in case of public guild (that can have 0 members) with 1 member who is leaving
if (seniorMember) update.$set = {leader: seniorMember._id};
}
promises.push(group.update(update).exec());
}
firebase.removeUserFromGroup(group._id, user._id);
return await Bluebird.all(promises);
};
// API v2 compatibility methods
schema.methods.getTransformedData = function getTransformedData (options) {
let cb = options.cb;
let populateMembers = options.populateMembers;
let populateInvites = options.populateInvites;
let populateChallenges = options.populateChallenges;
let obj = this.toJSON();
let queryMembers = {};
let queryInvites = {};
if (this.type === 'guild') {
queryInvites['invitations.guilds.id'] = this._id;
} else {
queryInvites['invitations.party.id'] = this._id;
}
if (this.type === 'guild') {
queryMembers.guilds = this._id;
} else {
queryMembers['party._id'] = this._id;
}
let selectDataMembers = '_id';
let selectDataInvites = '_id';
let selectDataChallenges = '_id';
if (populateMembers) {
selectDataMembers += ` ${populateMembers}`;
}
if (populateInvites) {
selectDataInvites += ` ${populateInvites}`;
}
if (populateChallenges) {
selectDataChallenges += ` ${populateChallenges}`;
}
let membersQuery = User.find(queryMembers).select(selectDataMembers);
if (options.limitPopulation) membersQuery.limit(15);
Bluebird.all([
membersQuery.exec(),
User.find(queryInvites).select(populateInvites).exec(),
Challenge.find({group: obj._id}).select(populateMembers).exec(),
])
.then((results) => {
obj.members = results[0];
obj.invites = results[1];
obj.challenges = results[2];
cb(null, obj);
})
.catch(cb);
};
// END API v2 compatibility methods
export let model = mongoose.model('Group', schema);
// initialize tavern if !exists (fresh installs)
// do not run when testing as it's handled by the tests and can easily cause a race condition
if (!nconf.get('IS_TEST')) {
model.count({_id: TAVERN_ID}, (err, ct) => {
if (err) throw err;
if (ct > 0) return;
new model({ // eslint-disable-line babel/new-cap
_id: TAVERN_ID,
leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie
name: 'Tavern',
type: 'guild',
privacy: 'public',
}).save();
});
}
+27
View File
@@ -0,0 +1,27 @@
import mongoose from 'mongoose';
import baseModel from '../libs/api-v3/baseModel';
import { v4 as uuid } from 'uuid';
import validator from 'validator';
let Schema = mongoose.Schema;
export let schema = new Schema({
id: {
type: String,
default: uuid,
validate: [validator.isUUID, 'Invalid uuid.'],
},
name: {type: String, required: true},
challenge: {type: String},
}, {
strict: true,
minimize: false, // So empty objects are returned
_id: false, // use id instead of _id
});
schema.plugin(baseModel, {
noSet: ['_id', 'id', 'challenge'],
_id: false, // use id instead of _id
});
export let model = mongoose.model('Tag', schema);
+222
View File
@@ -0,0 +1,222 @@
import mongoose from 'mongoose';
import shared from '../../../common';
import validator from 'validator';
import moment from 'moment';
import baseModel from '../libs/api-v3/baseModel';
import _ from 'lodash';
import { preenHistory } from '../libs/api-v3/preening';
let Schema = mongoose.Schema;
let discriminatorOptions = {
discriminatorKey: 'type', // the key that distinguishes task types
};
let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id: false});
export let tasksTypes = ['habit', 'daily', 'todo', 'reward'];
// Important
// When something changes here remember to update the client side model at common/script/libs/taskDefaults
export let TaskSchema = new Schema({
_legacyId: String, // TODO Remove when v2 is deprecated
type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]},
text: {type: String, required: true},
notes: {type: String, default: ''},
tags: [{
type: String,
validate: [validator.isUUID, 'Invalid uuid.'],
}],
value: {type: Number, default: 0, required: true}, // redness or cost for rewards Required because it must be settable (for rewards)
priority: {
type: Number,
default: 1,
required: true,
validate: [
(val) => [0.1, 1, 1.5, 2].indexOf(val) !== -1,
'Valid priority values are 0.1, 1, 1.5, 2.',
],
},
attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']},
userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge
challenge: {
id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task
taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task
broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED', 'CHALLENGE_TASK_NOT_FOUND']}, // CHALLENGE_TASK_NOT_FOUND comes from v3 migration
winner: String, // user.profile.name of the winner
},
reminders: [{
_id: false,
id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true},
startDate: {type: Date},
time: {type: Date, required: true},
}],
}, _.defaults({
minimize: true, // So empty objects are returned
strict: true,
}, discriminatorOptions));
TaskSchema.plugin(baseModel, {
noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId'],
sanitizeTransform (taskObj) {
if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards
delete taskObj.value;
}
return taskObj;
},
private: [],
timestamps: true,
});
// Sanitize user tasks linked to a challenge
// See http://habitica.wikia.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info
TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) {
let initialSanitization = this.sanitize(taskObj);
return _.pick(initialSanitization, ['streak', 'checklist', 'attribute', 'reminders', 'tags', 'notes']);
};
// Sanitize checklist objects (disallowing id)
TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) {
delete checklistObj.id;
return checklistObj;
};
// Sanitize reminder objects (disallowing id)
TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) {
delete reminderObj.id;
return reminderObj;
};
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta) {
let chalTask = this;
chalTask.value += delta;
if (chalTask.type === 'habit' || chalTask.type === 'daily') {
// Add only one history entry per day
let lastChallengHistoryIndex = chalTask.history.length - 1;
if (chalTask.history[lastChallengHistoryIndex] &&
moment(chalTask.history[lastChallengHistoryIndex].date).isSame(new Date(), 'day')) {
chalTask.history[lastChallengHistoryIndex] = {
date: Number(new Date()),
value: chalTask.value,
};
chalTask.markModified(`history.${lastChallengHistoryIndex}`);
} else {
chalTask.history.push({
date: Number(new Date()),
value: chalTask.value,
});
// Only preen task history once a day when the task is scored first
if (chalTask.history.length > 365) {
chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user
}
}
}
await chalTask.save();
};
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model)
// These will be removed once API v2 is discontinued
// toJSON for API v2
TaskSchema.methods.toJSONV2 = function toJSONV2 () {
let toJSON = this.toJSON();
if (toJSON._legacyId) {
toJSON.id = toJSON._legacyId;
} else {
toJSON.id = toJSON._id;
}
if (!toJSON.challenge) toJSON.challenge = {};
let v3Tags = this.tags;
toJSON.tags = {};
v3Tags.forEach(tag => {
toJSON.tags[tag] = true;
});
toJSON.dateCreated = this.createdAt;
return toJSON;
};
TaskSchema.statics.fromJSONV2 = function fromJSONV2 (taskObj) {
if (taskObj.id) taskObj._id = taskObj.id;
let v2Tags = taskObj.tags || {};
taskObj.tags = [];
taskObj.tags = _.map(v2Tags, (tag, key) => key);
return taskObj;
};
// END of API v2 methods
export let Task = mongoose.model('Task', TaskSchema);
// habits and dailies shared fields
let habitDailySchema = () => {
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems
};
// dailys and todos shared fields
let dailyTodoSchema = () => {
return {
completed: {type: Boolean, default: false},
// Checklist fields (dailies and todos)
collapseChecklist: {type: Boolean, default: false},
checklist: [{
completed: {type: Boolean, default: false},
text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation
_id: false,
id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']},
}],
};
};
export let HabitSchema = new Schema(_.defaults({
up: {type: Boolean, default: true},
down: {type: Boolean, default: true},
}, habitDailySchema()), subDiscriminatorOptions);
export let habit = Task.discriminator('habit', HabitSchema);
export let DailySchema = new Schema(_.defaults({
frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']},
everyX: {type: Number, default: 1}, // e.g. once every X weeks
startDate: {
type: Date,
default () {
return moment().startOf('day').toDate();
},
},
repeat: { // used only for 'weekly' frequency,
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},
},
streak: {type: Number, default: 0},
}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions);
export let daily = Task.discriminator('daily', DailySchema);
export let TodoSchema = new Schema(_.defaults({
dateCompleted: Date,
// TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript
date: String, // due date for todos
}, dailyTodoSchema()), subDiscriminatorOptions);
export let todo = Task.discriminator('todo', TodoSchema);
export let RewardSchema = new Schema({}, subDiscriminatorOptions);
export let reward = Task.discriminator('reward', RewardSchema);
+824
View File
@@ -0,0 +1,824 @@
import mongoose from 'mongoose';
import shared from '../../../common';
import _ from 'lodash';
import validator from 'validator';
import moment from 'moment';
import * as Tasks from './task';
import Bluebird from 'bluebird';
import { schema as TagSchema } from './tag';
import baseModel from '../libs/api-v3/baseModel';
import {
chatDefaults,
TAVERN_ID,
} from './group';
import { defaults } from 'lodash';
let Schema = mongoose.Schema;
// User schema definition
export let schema = new Schema({
apiToken: {
type: String,
default: shared.uuid,
},
auth: {
blocked: Boolean,
facebook: {type: Schema.Types.Mixed, default: () => {
return {};
}},
local: {
email: {
type: String,
validate: [validator.isEmail, shared.i18n.t('invalidEmail')],
},
username: {
type: String,
},
// Store a lowercase version of username to check for duplicates
lowerCaseUsername: String,
hashed_password: String, // eslint-disable-line camelcase
salt: String,
},
timestamps: {
created: {type: Date, default: Date.now},
loggedin: {type: Date, default: Date.now},
},
},
// 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 },
migration: String,
achievements: {
originalUser: Boolean,
habitSurveys: Number,
ultimateGearSets: {
healer: {type: Boolean, default: false},
wizard: {type: Boolean, default: false},
rogue: {type: Boolean, default: false},
warrior: {type: Boolean, default: false},
},
beastMaster: Boolean,
beastMasterCount: Number,
mountMaster: Boolean,
mountMasterCount: Number,
triadBingo: Boolean,
triadBingoCount: Number,
veteran: Boolean,
snowball: Number,
spookySparkles: Number,
shinySeed: Number,
seafoam: Number,
streak: Number,
challenges: Array,
quests: {type: Schema.Types.Mixed, default: () => {
return {};
}},
rebirths: Number,
rebirthLevel: Number,
perfect: {type: Number, default: 0},
habitBirthdays: Number,
valentine: Number,
costumeContest: Boolean, // Superseded by costumeContests
nye: Number,
habiticaDays: Number,
greeting: Number,
thankyou: Number,
costumeContests: Number,
birthday: Number,
partyUp: Boolean,
partyOn: Boolean,
},
backer: {
tier: Number,
npc: String,
tokensApplied: Boolean,
},
contributor: {
// 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801
level: {
type: Number,
min: 0,
max: 9,
},
admin: Boolean,
sudo: Boolean,
// Artisan, Friend, Blacksmith, etc
text: String,
// a markdown textarea to list their contributions + links
contributions: String,
critical: String,
},
balance: {type: Number, default: 0},
// Not saved on the user right now
filters: {type: Schema.Types.Mixed, default: () => {
return {};
}},
purchased: {
ads: {type: Boolean, default: false},
// eg, {skeleton: true, pumpkin: true, eb052b: true}
skin: {type: Schema.Types.Mixed, default: () => {
return {};
}},
hair: {type: Schema.Types.Mixed, default: () => {
return {};
}},
shirt: {type: Schema.Types.Mixed, default: () => {
return {};
}},
background: {type: Schema.Types.Mixed, default: () => {
return {};
}},
txnCount: {type: Number, default: 0},
mobileChat: Boolean,
plan: {
planId: String,
paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']}
customerId: String, // Billing Agreement Id in case of Amazon Payments
dateCreated: Date,
dateTerminated: Date,
dateUpdated: Date,
extraMonths: {type: Number, default: 0},
gemsBought: {type: Number, default: 0},
mysteryItems: {type: Array, default: () => []},
lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date
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},
tour: {
// -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index)
intro: {type: Number, default: -1},
classes: {type: Number, default: -1},
stats: {type: Number, default: -1},
tavern: {type: Number, default: -1},
party: {type: Number, default: -1},
guilds: {type: Number, default: -1},
challenges: {type: Number, default: -1},
market: {type: Number, default: -1},
pets: {type: Number, default: -1},
mounts: {type: Number, default: -1},
hall: {type: Number, default: -1},
equipment: {type: Number, default: -1},
},
tutorial: {
common: {
habits: {type: Boolean, default: false},
dailies: {type: Boolean, default: false},
todos: {type: Boolean, default: false},
rewards: {type: Boolean, default: false},
party: {type: Boolean, default: false},
pets: {type: Boolean, default: false},
gems: {type: Boolean, default: false},
skills: {type: Boolean, default: false},
classes: {type: Boolean, default: false},
tavern: {type: Boolean, default: false},
equipment: {type: Boolean, default: false},
items: {type: Boolean, default: false},
},
ios: {
addTask: {type: Boolean, default: false},
editTask: {type: Boolean, default: false},
deleteTask: {type: Boolean, default: false},
filterTask: {type: Boolean, default: false},
groupPets: {type: Boolean, default: false},
inviteParty: {type: Boolean, default: false},
},
},
dropsEnabled: {type: Boolean, default: false},
itemsEnabled: {type: Boolean, default: false},
newStuff: {type: Boolean, default: false},
rewrite: {type: Boolean, default: true},
contributor: Boolean,
classSelected: {type: Boolean, default: false},
mathUpdates: Boolean,
rebirthEnabled: {type: Boolean, default: false},
levelDrops: {type: Schema.Types.Mixed, default: () => {
return {};
}},
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},
// Needed to track the tip to send inside the email
weeklyRecapEmailsPhase: {type: Number, default: 0},
// Used to track when the next weekly recap should be sent
lastWeeklyRecap: {type: Date, default: Date.now},
// Used to enable weekly recap emails as users login
lastWeeklyRecapDiscriminator: Boolean,
communityGuidelinesAccepted: {type: Boolean, default: false},
cronCount: {type: Number, default: 0},
welcomed: {type: Boolean, default: false},
armoireEnabled: {type: Boolean, default: false},
armoireOpened: {type: Boolean, default: false},
armoireEmpty: {type: Boolean, default: false},
cardReceived: {type: Boolean, default: false},
warnedLowHealth: {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
},
items: {
gear: {
owned: _.transform(shared.content.gear.flat, (m, v) => {
m[v.key] = {type: Boolean};
if (v.key.match(/[armor|head|shield]_warrior_0/) || v.gearSet === 'glasses') {
m[v.key].default = true;
}
}),
equipped: {
weapon: String,
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: String,
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},
spookySparkles: {type: Number, default: 0},
shinySeed: {type: Number, default: 0},
seafoam: {type: Number, default: 0},
valentine: {type: Number, default: 0},
valentineReceived: Array, // array of strings, by sender name
nye: {type: Number, default: 0},
nyeReceived: Array,
greeting: {type: Number, default: 0},
greetingReceived: Array,
thankyou: {type: Number, default: 0},
thankyouReceived: Array,
birthday: {type: Number, default: 0},
birthdayReceived: 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, (m, v, k) => m[k] = Number),
// Then add additional pets (quest, backer, contributor, premium)
_.transform(shared.content.questPets, (m, v, k) => m[k] = Number),
_.transform(shared.content.specialPets, (m, v, k) => m[k] = Number),
_.transform(shared.content.premiumPets, (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, (m, v, k) => m[k] = Number),
// hatchingPotions: {
// 'Desert': 0, // 0 indicates "doesn't own"
// 'CottonCandyBlue': 5 // Number indicates "stacking"
// }
hatchingPotions: _.transform(shared.content.hatchingPotions, (m, v, k) => m[k] = Number),
// Food: {
// 'Watermelon': 0, // 0 indicates "doesn't own"
// 'RottenMeat': 5 // Number indicates "stacking"
// }
food: _.transform(shared.content.food, (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, (m, v, k) => m[k] = Boolean),
// Then add quest and premium pets
_.transform(shared.content.questPets, (m, v, k) => m[k] = Boolean),
_.transform(shared.content.premiumPets, (m, v, k) => m[k] = Boolean),
// Then add additional mounts (backer, contributor)
_.transform(shared.content.specialMounts, (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, (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: () => {
return {};
}},
challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}],
invitations: {
// Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default
// Schema is (id, name, inviter)
// TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id
guilds: {type: Array, default: () => []},
// Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO
// schema is the same as for guild invitations (id, name, inviter)
party: {type: Schema.Types.Mixed, default: () => {
return {};
}},
},
guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}],
party: {
_id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'},
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: () => {
return {};
}}, // {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
RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled
},
},
preferences: {
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: {type: Number, default: 0},
sound: {type: String, default: 'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']},
chair: {type: String, default: 'none'},
timezoneOffsetAtLastCron: Number,
language: String,
automaticAllocation: Boolean,
allocationMode: {type: String, enum: ['flat', 'classbased', 'taskbased'], default: 'flat'},
autoEquip: {type: Boolean, default: true},
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},
reverseChatOrder: {type: Boolean, default: false},
background: String,
displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true},
webhooks: {type: Schema.Types.Mixed, default: () => {
return {};
}},
// For the following 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},
kickedGroup: {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 are in fact the recapture emails
importantAnnouncements: {type: Boolean, default: true},
weeklyRecaps: {type: Boolean, default: true},
},
suppressModals: {
levelUp: {type: Boolean, default: false},
hatchPet: {type: Boolean, default: false},
raisePet: {type: Boolean, default: false},
streak: {type: Boolean, default: false},
},
improvementCategories: {
type: Array,
validate: (categories) => {
const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity'];
let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1);
return isValidCategory;
},
},
},
profile: {
blurb: String,
imageUrl: String,
name: String,
},
stats: {
hp: {type: Number, default: shared.maxHealth},
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', required: true},
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},
spookySparkles: {type: Boolean, default: false},
shinySeed: {type: Boolean, default: false},
seafoam: {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: [TagSchema],
inbox: {
newMessages: {type: Number, default: 0},
blocks: {type: Array, default: () => []},
messages: {type: Schema.Types.Mixed, default: () => {
return {};
}},
optOut: {type: Boolean, default: false},
},
tasksOrder: {
habits: [{type: String, ref: 'Task'}],
dailys: [{type: String, ref: 'Task'}],
todos: [{type: String, ref: 'Task'}],
rewards: [{type: String, ref: 'Task'}],
},
extra: {type: Schema.Types.Mixed, default: () => {
return {};
}},
pushDevices: {
type: [{
regId: {type: String},
type: {type: String},
}],
default: () => [],
},
}, {
strict: true,
minimize: false, // So empty objects are returned
});
schema.plugin(baseModel, {
// noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...)
noSet: [],
private: ['auth.local.hashed_password', 'auth.local.salt'],
toJSONTransform: function userToJSON (plainObj, originalDoc) {
// plainObj.filters = {}; // TODO Not saved, remove?
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs
return plainObj;
},
});
// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private)
export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt
preferences.costume preferences.sleep preferences.background profile stats achievements party
backer contributor auth.timestamps items`;
// The minimum amount of data needed when populating multiple users
export let nameFields = 'profile.name';
schema.post('init', function postInitUser (doc) {
shared.wrap(doc);
});
function _populateDefaultTasks (user, taskTypes) {
let tagsI = taskTypes.indexOf('tag');
if (tagsI !== -1) {
user.tags = _.map(shared.content.userDefaults.tags, (tag) => {
let newTag = _.cloneDeep(tag);
// tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here
newTag.id = shared.uuid();
// Render tag's name in user's language
newTag.name = newTag.name(user.preferences.language);
return newTag;
});
}
let tasksToCreate = [];
if (tagsI !== -1) {
taskTypes = _.clone(taskTypes);
taskTypes.splice(tagsI, 1);
}
_.each(taskTypes, (taskType) => {
let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => {
let newTask = new Tasks[taskType](taskDefaults);
newTask.userId = user._id;
newTask.text = taskDefaults.text(user.preferences.language);
if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language);
if (taskDefaults.checklist) {
newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => {
checklistItem.text = checklistItem.text(user.preferences.language);
return checklistItem;
});
}
return newTask.save();
});
tasksToCreate.push(...tasksOfType);
});
return Bluebird.all(tasksToCreate)
.then((tasksCreated) => {
_.each(tasksCreated, (task) => {
user.tasksOrder[`${task.type}s`].push(task._id);
});
});
}
function _populateDefaultsForNewUser (user) {
let taskTypes;
let iterableFlags = user.flags.toObject();
if (user.registeredThrough === 'habitica-web' || user.registeredThrough === 'habitica-android') {
taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag'];
_.each(iterableFlags.tutorial.common, (val, section) => {
user.flags.tutorial.common[section] = true;
});
} else {
taskTypes = ['todo', 'tag'];
user.flags.showTour = false;
_.each(iterableFlags.tour, (val, section) => {
user.flags.tour[section] = -2;
});
}
return _populateDefaultTasks(user, taskTypes);
}
function _setProfileName (user) {
let fb = user.auth.facebook;
let localUsername = user.auth.local && user.auth.local.username;
let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`);
let anonymous = 'Anonymous';
return localUsername || facebookUsername || anonymous;
}
schema.pre('save', true, function preSaveUser (next, done) {
next();
if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) {
this.preferences.dayStart = 0;
}
if (!this.profile.name) {
this.profile.name = _setProfileName(this);
}
// Determines if Beast Master should be awarded
let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets);
if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) {
this.achievements.beastMaster = true;
}
// Determines if Mount Master should be awarded
let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts);
if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) {
this.achievements.mountMaster = true;
}
// Determines if Triad Bingo should be awarded
let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets);
let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90;
if (qualifiesForTriad || this.achievements.triadBingoCount > 0) {
this.achievements.triadBingo = true;
}
// Enable weekly recap emails for old users who sign in
if (this.flags.lastWeeklyRecapDiscriminator) {
// Enable weekly recap emails in 24 hours
this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate();
// Unset the field so this is run only once
this.flags.lastWeeklyRecapDiscriminator = undefined;
}
// 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++;
// Populate new users with default content
if (this.isNew) {
_populateDefaultsForNewUser(this)
.then(() => done())
.catch(done);
} else {
done();
}
});
schema.pre('update', function preUpdateUser () {
this.update({}, {$inc: {_v: 1}});
});
schema.methods.isSubscribed = function isSubscribed () {
return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion
};
// Get an array of groups ids the user is member of
schema.methods.getGroups = function getUserGroups () {
let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original
if (this.party._id) userGroups.push(this.party._id);
userGroups.push(TAVERN_ID);
return userGroups;
};
schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) {
let sender = this;
shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender));
userToReceiveMessage.inbox.newMessages++;
userToReceiveMessage._v++;
userToReceiveMessage.markModified('inbox.messages');
shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage)));
sender.markModified('inbox.messages');
let promises = [userToReceiveMessage.save(), sender.save()];
await Bluebird.all(promises);
};
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model)
// These will be removed once API v2 is discontinued
// Get all the tasks belonging to a user,
schema.methods.getTasks = function getUserTasks () {
let args = Array.from(arguments);
let cb;
let type;
if (args.length === 1) {
cb = args[0];
} else {
type = args[0];
cb = args[1];
}
let query = {
userId: this._id,
};
if (type) query.type = type;
Tasks.Task.find(query, cb);
};
// Given user and an array of tasks, return an API compatible user + tasks obj
schema.methods.addTasksToUser = function addTasksToUser (tasks) {
let obj = this.toJSON();
obj.id = obj._id;
obj.filters = {};
obj.tags = obj.tags.map(tag => {
return {
id: tag.id,
name: tag.name,
challenge: tag.challenge,
};
});
let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it
obj.habits = [];
obj.dailys = [];
obj.todos = [];
obj.rewards = [];
obj.tasksOrder = undefined;
let unordered = [];
tasks.forEach((task) => {
// We want to push the task at the same position where it's stored in tasksOrder
let pos = tasksOrder[`${task.type}s`].indexOf(task._id);
if (pos === -1) { // Should never happen, it means the lists got out of sync
unordered.push(task.toJSONV2());
} else {
obj[`${task.type}s`][pos] = task.toJSONV2();
}
});
// Reconcile unordered items
unordered.forEach((task) => {
obj[`${task.type}s`].push(task);
});
// Remove null values that can be created when inserting tasks at an index > length
['habits', 'dailys', 'rewards', 'todos'].forEach((type) => {
obj[type] = _.compact(obj[type]);
});
return obj;
};
// Return the data maintaining backward compatibility
schema.methods.getTransformedData = function getTransformedData (cb) {
let self = this;
this.getTasks((err, tasks) => {
if (err) return cb(err);
cb(null, self.addTasksToUser(tasks));
});
};
// END of API v2 methods
export let model = mongoose.model('User', schema);
// Initially export an empty object so external requires will get
// the right object by reference when it's defined later
// Otherwise it would remain undefined if requested before the query executes
export let mods = [];
mongoose.model('User')
.find({'contributor.admin': true})
.sort('-contributor.level -backer.npc profile.name')
.select('profile contributor backer')
.exec()
.then((foundMods) => {
// Using push to maintain the reference to mods
mods.push(...foundMods);
}); // In case of failure we don't want this to crash the whole server
+21
View File
@@ -0,0 +1,21 @@
var auth = require('../../controllers/api-v2/auth');
var express = require('express');
var i18n = require('../../libs/api-v2/i18n');
var router = express.Router();
import {
getUserLanguage
} from '../../middlewares/api-v3/language';
/* auth.auth*/
// auth.setupPassport(router); //TODO make this consistent with the others
router.post('/register', getUserLanguage, auth.registerUser);
router.post('/user/auth/local', getUserLanguage, auth.loginLocal);
router.post('/user/auth/social', getUserLanguage, auth.loginSocial);
router.delete('/user/auth/social', getUserLanguage, auth.auth, auth.deleteSocial);
router.post('/user/reset-password', getUserLanguage, auth.resetPassword);
router.post('/user/change-password', getUserLanguage, auth.auth, auth.changePassword);
router.post('/user/change-username', getUserLanguage, auth.auth, auth.changeUsername);
router.post('/user/change-email', getUserLanguage, auth.auth, auth.changeEmail);
// router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
var nconf = require('nconf');
var express = require('express');
var router = express.Router();
var auth = require('../../controllers/api-v2/auth');
var coupon = require('../../controllers/api-v2/coupon');
var i18n = require('../../libs/api-v2/i18n');
import {
getUserLanguage
} from '../../middlewares/api-v3/language';
router.get('/coupons', auth.authWithUrl, getUserLanguage, coupon.ensureAdmin, coupon.getCoupons);
router.post('/coupons/generate/:event', auth.auth, getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons);
router.post('/user/coupon/:code', auth.auth, getUserLanguage, coupon.enterCode);
module.exports = router;
+778
View File
@@ -0,0 +1,778 @@
/*
---------- /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.js#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:
*/
var user = require("../../controllers/api-v2/user");
var groups = require("../../controllers/api-v2/groups");
var members = require("../../controllers/api-v2/members");
var auth = require("../../controllers/api-v2/auth");
var hall = require("../../controllers/api-v2/hall");
var challenges = require("../../controllers/api-v2/challenges");
var dataexport = require("../../controllers/api-v2/dataexport");
var nconf = require("nconf");
var cron = user.cron;
var _ = require('lodash');
var content = require('../../../../common').content;
var i18n = require('../../libs/api-v2/i18n');
import {
getUserLanguage
} from '../../middlewares/api-v3/language';
var forceRefresh = require('../../middlewares/forceRefresh').middleware;
module.exports = function(swagger, v2) {
var path = swagger.pathParam;
var body = swagger.bodyParam;
var query = swagger.queryParam;
swagger.setAppHandler(v2);
swagger.setErrorHandler("next");
swagger.setHeaders = function() {};
swagger.configureSwaggerPaths("", "/api-docs", "");
var api = {
'/status': {
spec: {
description: "Returns the status of the server (up or down). Does not require authentication."
},
action: function(req, res) {
return 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). Does not require authentication.",
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. Does not require authentication."
},
action: user.getModelPaths
},
"/export/history": {
spec: {
description: "Export user history",
method: 'GET'
},
middleware: [auth.auth, getUserLanguage],
action: dataexport.history
},
"/user/tasks/{id}/{direction}": {
spec: {
description: "Simple scoring of a task (Habit, Daily, To-Do, or Reward). 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 <b>user._tmp</b> variable will be filled. Critical hits can be accessed through <b>user._tmp.crit</b>. The Streakbonus can be accessed through <b>user._tmp.streakBonus</b>. Both will contain the multiplier value. When random drops occur, the following values are available: <b>user._tmp.drop = {text,type,dialog,value,key,notes}</b>",
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
},
"/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, getUserLanguage],
action: challenges.unlink
},
"/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: [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-purchasable 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/hourglass/{type}/{key}": {
spec: {
method: 'POST',
description: "Purchase a pet or mount using a Mystic Hourglass",
parameters: [path('type', "The type of object you're purchasing.", 'string', ['pets', 'mounts']), path('key', "The object key you're purchasing (call /content route for available keys)", 'string')]
},
action: user.hourglassPurchase
},
"/user/inventory/mystery/{key}": {
spec: {
method: 'POST',
description: "Purchase a Mystery Item Set using a Mystic Hourglass",
parameters: [path('key', "The key for the Mystery Set you're purchasing (call /content route for available keys)", 'string')]
},
action: user.buyMysterySet
},
"/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:GET": {
spec: {
path: '/user',
description: "Get the full user object"
},
action: user.getUser
},
"/user/anonymized": {
spec: {
description: "Get the user object without any personal data"
},
action: user.getUserAnonymized
},
"/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, 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/index.js)", '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: [forceRefresh, auth.auth, getUserLanguage, cron, user.sessionPartyInvite],
action: user.batchUpdate
},
"/user/tags/{id}:GET": {
spec: {
path: '/user/tags/{id}',
method: 'GET',
description: "Get a tag",
parameters: [path('id', 'The id of the tag to get', 'string')]
},
action: user.getTag
},
"/user/tags:POST": {
spec: {
path: "/user/tags",
method: 'POST',
description: 'Create a new tag',
parameters: [body('', 'New tag (see UserSchema.tags)', 'object')]
},
action: user.addTag
},
"/user/tags:GET": {
spec: {
path: "/user/tags",
method: 'GET',
description: 'List all of a user\'s tags'
},
action: user.getTags
},
"/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/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
},
"/user/pushDevice": {
spec: {
method: 'POST',
description: 'Add a new push devices registration ID',
parameters: [body('', 'New push registration { regId: "123123", type: "android"}', 'object')]
},
action: user.addPushDevice
},
"/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, 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, 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, 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, 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, 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, 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'), body('', 'a payload of invites either under body.uuids or body.emails, only one of them!', 'object')]
},
middleware: [auth.auth, 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, 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, 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, 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, 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, getUserLanguage, groups.attachGroup],
action: groups.questAbort
},
"/groups/{gid}/questLeave": {
spec: {
method: 'POST',
description: 'Leave an active quest (Quest leaders cannot leave active quests. They must abort the quest to leave)',
parameters: [path('gid', 'Group to leave quest in', 'string')]
},
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
action: groups.questLeave
},
"/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, 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, getUserLanguage, groups.attachGroup],
action: groups.postChat
},
"/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, 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, 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, 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, getUserLanguage, groups.attachGroup],
action: groups.clearFlagCount
},
"/members/{uuid}:GET": {
spec: {
path: '/members/{uuid}',
description: "Get a member.",
parameters: [path('uuid', 'Member ID', 'string')]
},
middleware: [getUserLanguage],
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/heroes": {
spec: {},
middleware: [auth.auth, getUserLanguage],
action: hall.getHeroes
},
"/hall/heroes/{uid}:GET": {
spec: {
path: "/hall/heroes/{uid}"
},
middleware: [auth.auth, getUserLanguage, hall.ensureAdmin],
action: hall.getHero
},
"/hall/heroes/{uid}:POST": {
spec: {
method: 'POST',
path: "/hall/heroes/{uid}"
},
middleware: [auth.auth, getUserLanguage, hall.ensureAdmin],
action: hall.updateHero
},
"/hall/patrons": {
spec: {
parameters: [query('page', 'Page number to fetch (this list is long)', 'string')]
},
middleware: [auth.auth, getUserLanguage],
action: hall.getPatrons
},
"/challenges:GET": {
spec: {
path: '/challenges',
description: "Get a list of challenges"
},
middleware: [auth.auth, 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, getUserLanguage],
action: challenges.create
},
"/challenges/{cid}:GET": {
spec: {
path: '/challenges/{cid}',
description: 'Get a challenge',
parameters: [path('cid', 'Challenge id', 'string')]
},
middleware: [auth.auth, getUserLanguage],
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, 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, 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, getUserLanguage],
action: challenges.selectWinner
},
"/challenges/{cid}/join": {
spec: {
method: 'POST',
description: "Join a challenge",
parameters: [path('cid', 'Challenge id', 'string')]
},
middleware: [auth.auth, getUserLanguage],
action: challenges.join
},
"/challenges/{cid}/leave": {
spec: {
method: 'POST',
description: 'Leave a challenge',
parameters: [path('cid', 'Challenge id', 'string')]
},
middleware: [auth.auth, 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, getUserLanguage],
action: challenges.getMember
}
};
if (nconf.get("NODE_ENV") === "development") {
api["/user/addTenGems"] = {
spec: {
method: 'POST'
},
action: user.addTenGems
};
api["/user/addHourglass"] = {
spec: {
method: 'POST'
},
action: user.addHourglass
};
};
_.each(api, function(route, path) {
var base;
if ((base = route.spec).description == null) {
base.description = '';
}
_.defaults(route.spec, {
path: path,
nickname: path,
notes: route.spec.description,
summary: route.spec.description,
parameters: [],
errorResponses: [],
method: 'GET'
});
if (route.middleware == null) {
route.middleware = path.indexOf('/user') === 0 ? [auth.auth, getUserLanguage, cron] : [i18n.getUserLanguage];
}
swagger["add" + route.spec.method](route);
return true;
});
return swagger.configure((nconf.get('BASE_URL')) + "/api/v2", "2");
};
@@ -0,0 +1,11 @@
var express = require('express');
var router = express.Router();
var i18n = require('../../libs/api-v2/i18n');
var unsubscription = require('../../controllers/api-v2/unsubscription');
import {
getUserLanguage
} from '../../middlewares/api-v3/language';
router.get('/unsubscribe', getUserLanguage, unsubscription.unsubscribe);
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More