Merge branch 'develop' of github.com:HabitRPG/habitrpg into common-convert

Conflicts:
	src/controllers/auth.js
	src/controllers/challenges.js
	src/controllers/groups.js
	src/controllers/members.js
	src/controllers/payments/index.js
	src/controllers/user.js
	src/middleware.js
	src/models/user.js
This commit is contained in:
Blade Barringer
2015-02-03 21:13:55 -06:00
25 changed files with 600 additions and 250 deletions

View File

@@ -1,9 +1,9 @@
var migrationName = '20150131_birthday_goodies_fix_remove_robe.js';
var migrationName = '20150131_birthday_goodies_fix__one_birthday__1';
var authorName = 'Alys'; // in case script author needs to know when their ...
var authorUuid = 'd904bd62-da08-416b-a816-ba797c9ee265'; //... own data is done
/**
* remove new birthday robes from people who don't have original birthday achievement
/*
* remove new birthday robes and second achievement from people who shouldn't have them
*/
var dbserver = 'localhost:27017' // CHANGE THIS FOR PRODUCTION DATABASE
@@ -13,12 +13,22 @@ var _ = require('lodash');
var dbUsers = mongo.db(dbserver + '/habitrpg?auto_reconnect').collection('users');
// 'auth.timestamps.created':{$gt:new Date('2014-02-01')},
var query = {
'achievements.habitBirthday':{$exists:false}
};
'achievements.habitBirthdays':1,
'auth.timestamps.loggedin':{$gt:new Date('2014-12-20')}
};
// '_id': 'c03e41bd-501f-438c-9553-a7afdf52a08c',
// 'achievements.habitBirthday':{$exists:false},
// 'items.gear.owned.armor_special_birthday2015':1
var fields = {
'items.gear.owned.armor_special_birthday2015':1
// 'auth.timestamps.created':1,
// 'achievements.habitBirthday':1,
// 'achievements.habitBirthdays':1,
'items.gear.owned.armor_special_birthday2015':1,
// 'items.gear.owned.armor_special':1
};
console.warn('Updating users...');
@@ -33,9 +43,11 @@ dbUsers.findEach(query, fields, {batchSize:250}, function(err, user) {
count++;
var unset = {'items.gear.owned.armor_special_birthday2015': 1};
var set = {'migration': migrationName};
// var set = {'migration':migrationName, 'achievements.habitBirthdays':1 };
// var inc = {'xyz':1, _v:1};
dbUsers.update({_id:user._id}, {$unset:unset, $set:set}); // , $inc:inc});
dbUsers.update({_id:user._id}, {$unset:unset}); // , $inc:inc});
// dbUsers.update({_id:user._id}, {$unset:unset, $set:set});
// console.warn(user.auth.timestamps.created);
if (count%progressCount == 0) console.warn(count + ' ' + user._id);
if (user._id == authorUuid) console.warn(authorName + ' processed');

View File

@@ -0,0 +1,106 @@
var migrationName = '20150201_convert_creation_date_from_string_to_object__no_date_recent_signup';
//// var migrationName = '20150201_convert_creation_date_from_string_to_object';
var authorName = 'Alys'; // in case script author needs to know when their ...
var authorUuid = 'd904bd62-da08-416b-a816-ba797c9ee265'; //... own data is done
/*
* For users that have no value for auth.timestamps.created, assign them
* a recent value.
*
* NOTE:
* Before this script was used as described above, it was first used to
* find all users that have a auth.timestamps.created field that is a string
* rather than a date object and set it to be a date object. The code used
* for this has been commented out with four slashes: ////
*
* https://github.com/HabitRPG/habitrpg/issues/4601#issuecomment-72339846
*/
var dbserver = 'localhost:27017' // CHANGE THIS FOR PRODUCTION DATABASE
var mongo = require('mongoskin');
var _ = require('lodash');
var moment = require('moment');
var dbUsers = mongo.db(dbserver + '/habitrpg?auto_reconnect').collection('users');
var uuidArrayRecent=[ // recent users with no creation dates
'1a0d4b75-73ed-4937-974d-d504d6398884',
'1c7ebe27-1250-4f95-ba10-965580adbfd7',
'5f972121-4a6d-411c-95e9-7093d3e89b66',
'ae85818a-e336-4ccd-945e-c15cef975102',
'ba273976-d9fc-466c-975f-38559d34a824',
];
var query = {
'_id':{$in: uuidArrayRecent}
//// 'auth':{$exists:true},
//// 'auth.timestamps':{$exists:true},
//// 'auth.timestamps.created':{$not: {$lt:new Date('2018-01-01')}}
};
var fields = {
'_id':1,
'auth.timestamps.created':1
};
// 'achievements.habitBirthdays':1
console.warn('Updating users...');
var progressCount = 1000;
var count = 0;
dbUsers.findEach(query, fields, {batchSize:250}, function(err, user) {
if (err) { return exiting(1, 'ERROR! ' + err); }
if (!user) {
console.warn('All appropriate users found and modified.');
return displayData();
}
count++;
//// var oldDate = user.auth.timestamps.created;
//// var newDate = moment(oldDate).toDate();
var oldDate = 'none';
var newDate = moment('2015-01-11').toDate();
console.warn(user._id + ' == ' + oldDate + ' == ' + newDate);
//// var set = { 'migration': migrationName,
//// 'auth.timestamps.created': newDate,
//// 'achievements.habitBirthdays': 2,
//// 'items.gear.owned.head_special_nye':true,
//// 'items.gear.owned.head_special_nye2014':true,
//// 'items.gear.owned.armor_special_birthday':true,
//// 'items.gear.owned.armor_special_birthday2015':true,
//// };
var set = { 'migration': migrationName,
'auth.timestamps.created': newDate,
'achievements.habitBirthdays': 1,
'items.gear.owned.armor_special_birthday':true,
};
// var unset = {'items.gear.owned.armor_special_birthday2015': 1};
// var inc = {'xyz':1, _v:1};
dbUsers.update({_id:user._id}, {$set:set});
// dbUsers.update({_id:user._id}, {$unset:unset, $set:set, $inc:inc});
if (count%progressCount == 0) console.warn(count + ' ' + user._id);
if (user._id == authorUuid) console.warn(authorName + ' processed');
if (user._id == '9' ) console.warn('lefnire' + ' processed');
});
function displayData() {
console.warn('\n' + count + ' users processed\n');
return exiting(0);
}
function exiting(code, msg) {
code = code || 0; // 0 = success
if (code && !msg) { msg = 'ERROR!'; }
if (msg) {
if (code) { console.error(msg); }
else { console.log( msg); }
}
process.exit(code);
}

View File

@@ -213,6 +213,10 @@ window.habitrpg = angular.module('habitrpg',
url: "/subscription",
templateUrl: "partials/options.settings.subscription.html"
})
.state('options.settings.notifications', {
url: "/notifications",
templateUrl: "partials/options.settings.notifications.html"
})
var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID));
if (settings && settings.auth) {

View File

@@ -242,5 +242,14 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$
window.location.href = url;
window.location.reload(false);
}
// Universal method for sending HTTP methods
$rootScope.http = function(method, route, data, alertMsg){
$http[method](ApiUrl.get() + route, data).success(function(){
if (alertMsg) Notification.text(window.env.t(alertMsg));
User.sync();
});
// error will be handled via $http interceptor
}
}
]);

View File

@@ -77,33 +77,12 @@ habitrpg.controller('SettingsCtrl',
$rootScope.$state.go('tasks');
}
$scope.changeUsername = function(changeUser){
if (!changeUser.newUsername || !changeUser.password) {
return alert(window.env.t('fillAll'));
}
$http.post(ApiUrl.get() + '/api/v2/user/change-username', changeUser)
$scope.changeUser = function(attr, updates){
$http.post(ApiUrl.get() + '/api/v2/user/change-'+attr, updates)
.success(function(){
alert(window.env.t('usernameSuccess'));
$scope.changeUser = {};
alert(window.env.t(attr+'Success'));
_.each(updates, function(v,k){updates[k]=null;});
User.sync();
})
.error(function(data){
alert(data.err);
});
}
$scope.changePassword = function(changePass){
if (!changePass.oldPassword || !changePass.newPassword || !changePass.confirmNewPassword) {
return alert(window.env.t('fillAll'));
}
$http.post(ApiUrl.get() + '/api/v2/user/change-password', changePass)
.success(function(data, status, headers, config){
if (data.err) return alert(data.err);
alert(window.env.t('passSuccess'));
$scope.changePass = {};
})
.error(function(data, status, headers, config){
alert(data.err);
});
}

View File

@@ -61,6 +61,7 @@ habitrpg
link: function(scope, element, attrs) {
// $scope.obj needs to come from controllers, so we can pass by ref
scope.main = attrs.main;
scope.modal = attrs.modal;
var dailiesView;
if(User.user.preferences.dailyDueDefaultView) {
dailiesView = "remaining";

View File

@@ -23,6 +23,10 @@ var accountSuspended = function(uuid){
code: 'ACCOUNT_SUSPENDED'
};
}
// escape email for regex, then search case-insensitive. See http://stackoverflow.com/a/3561711/362790
var mongoEmailRegex = function(email){
return new RegExp('^' + email.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$', 'i');
}
api.auth = function(req, res, next) {
var uid = req.headers['x-api-user'];
@@ -61,55 +65,57 @@ api.authWithUrl = function(req, res, next) {
}
api.registerUser = function(req, res, next) {
var confirmPassword = req.body.confirmPassword,
email = req.body.email,
password = req.body.password,
username = req.body.username;
if (!(username && password && email)) return res.json(401, {err: ":username, :email, :password, :confirmPassword required"});
if (password !== confirmPassword) return res.json(401, {err: ":password and :confirmPassword don't match"});
if (!validator.isEmail(email)) return res.json(401, {err: ":email invalid"});
async.waterfall([
function(cb) {
User.findOne({'auth.local.email': email}, cb);
async.auto({
validate: function(cb) {
if (!(req.body.username && req.body.password && req.body.email))
return cb({code:401, err: ":username, :email, :password, :confirmPassword required"});
if (req.body.password !== req.body.confirmPassword)
return cb({code:401, err: ":password and :confirmPassword don't match"});
if (!validator.isEmail(req.body.email))
return cb({code:401, err: ":email invalid"});
cb();
},
function(found, cb) {
if (found) return cb("Email already taken");
User.findOne({'auth.local.username': username}, cb);
}, function(found, cb) {
var newUser, salt, user;
if (found) return cb("Username already taken");
salt = utils.makeSalt();
newUser = {
findEmail: function(cb) {
User.findOne({'auth.local.email': req.body.email}, cb);
},
findUname: function(cb) {
User.findOne({'auth.local.username': req.body.username}, cb);
},
findFacebook: function(cb){
User.findOne({_id: req.headers['x-api-user'], apiToken: req.headers['x-api-key']}, {auth:1}, cb);
},
register: ['validate', 'findEmail', 'findUname', 'findFacebook', function(cb, data) {
if (data.findEmail) return cb({code:401, err:"Email already taken"});
if (data.findUname) return cb({code:401, err:"Username already taken"});
var salt = utils.makeSalt();
var newUser = {
auth: {
local: {
username: username,
email: email,
username: req.body.username,
email: req.body.email,
salt: salt,
hashed_password: utils.encryptPassword(password, salt)
hashed_password: utils.encryptPassword(req.body.password, salt)
},
timestamps: {created: +new Date(), loggedIn: +new Date()}
}
};
newUser.preferences = newUser.preferences || {};
newUser.preferences.language = req.language; // User language detected from browser, not saved
user = new User(newUser);
// temporary for conventions
if (req.subdomains[0] == 'con') {
_.each(user.dailys, function(h){
h.repeat = {m:false,t:false,w:false,th:false,f:false,s:false,su:false};
})
user.extra = {signupEvent: 'wondercon'};
// existing user, allow them to add local authentication
if (data.findFacebook) {
data.findFacebook.auth.local = newUser.auth.local;
data.findFacebook.save(cb);
// new user, register them
} else {
newUser.preferences = newUser.preferences || {};
newUser.preferences.language = req.language; // User language detected from browser, not saved
var user = new User(newUser);
utils.txnEmail(user, 'welcome');
ga.event('register', 'Local').send();
user.save(cb);
}
user.save(cb);
if(isProd) utils.txnEmail({name:username, email:email}, 'welcome');
ga.event('register', 'Local').send()
}
], function(err, saved) {
if (err) return res.json(401, {err: err});
res.json(200, saved);
email = password = username = null;
}]
}, function(err, data) {
if (err) return err.code ? res.json(err.code, err) : next(err);
res.json(200, data.register[0]);
});
};
@@ -173,9 +179,7 @@ api.loginSocial = function(req, res, next) {
user = new User(user);
user.save(cb);
if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) {
utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome');
}
utils.txnEmail(user, 'welcome');
ga.event('register', network).send();
}]
}, function(err, results){
@@ -188,9 +192,18 @@ api.loginSocial = function(req, res, next) {
/**
* DELETE /user/auth/social
* TODO implement
*/
api.deleteSocial = function(req,res,next){next()}
api.deleteSocial = function(req,res,next){
if (!res.locals.user.auth.local.username)
return res.json(401, {err:"Account lacks another authentication method, can't detach Facebook"});
//FIXME for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123
//res.locals.user.auth.facebook = null;
//res.locals.user.auth.save(function(err, saved){
User.update({_id:res.locals.user._id}, {$unset:{'auth.facebook':1}}, function(err){
if (err) return next(err);
res.send(200);
})
}
api.resetPassword = function(req, res, next){
var email = req.body.email,
@@ -198,9 +211,7 @@ api.resetPassword = function(req, res, next){
newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later)
hashed_password = utils.encryptPassword(newPassword, salt);
// escape email for regex, then search case-insensitive. See http://stackoverflow.com/a/3561711/362790
var emailRegExp = new RegExp('^' + email.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$', 'i');
User.findOne({'auth.local.email':emailRegExp}, function(err, user){
User.findOne({'auth.local.email':mongoEmailRegex(email)}, function(err, user){
if (err) return next(err);
if (!user) return res.send(500, {err:"Couldn't find a user registered for email " + email});
user.auth.local.salt = salt;
@@ -217,28 +228,45 @@ api.resetPassword = function(req, res, next){
});
};
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,
password = req.body.password,
newUsername = req.body.newUsername;
async.waterfall([
function(cb){
User.findOne({'auth.local.username': req.body.username}, {auth:1}, cb);
},
function(found, cb){
if (found) return cb({code:401, err: "Username already taken"});
if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password));
res.locals.user.auth.local.username = req.body.username;
res.locals.user.save(cb);
}
], function(err){
if (err) return err.code ? res.json(err.code, err) : next(err);
res.send(200);
})
}
User.findOne({'auth.local.username': newUsername}, function(err, result) {
if (err) next(err);
if(result) return res.json(401, {err: "Username already taken"});
var salt = user.auth.local.salt;
var hashed_password = utils.encryptPassword(password, salt);
if (hashed_password !== user.auth.local.hashed_password)
return res.json(401, {err:"Incorrect password"});
user.auth.local.username = newUsername;
user.save(function(err, saved){
if (err) next(err);
res.send(200);
user = password = newUsername = null;
})
});
api.changeEmail = function(req, res, next){
async.waterfall([
function(cb){
User.findOne({'auth.local.email': mongoEmailRegex(req.body.email)}, {auth:1}, cb);
},
function(found, cb){
if(found) return cb({code:401, err: "Email already taken"});
if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password));
res.locals.user.auth.local.email = req.body.email;
res.locals.user.save(cb);
}
], function(err){
if (err) return err.code ? res.json(err.code,err) : next(err);
res.send(200);
})
}
api.changePassword = function(req, res, next) {

View File

@@ -9,6 +9,7 @@ var Group = require('./../models/group').model;
var Challenge = require('./../models/challenge').model;
var logging = require('./../logging');
var csv = require('express-csv');
var utils = require('../utils');
var api = module.exports;
@@ -335,6 +336,11 @@ api.selectWinner = function(req, res, next) {
winner.save(cb);
},
function(saved, num, cb) {
if(saved.preferences.emailNotifications.wonChallenge !== false){
utils.txnEmail(saved, 'won-challenge', [
{name: 'CHALLENGE_NAME', content: chal.name}
]);
}
closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb);
}
], function(err){

View File

@@ -301,13 +301,11 @@ api.flagChatMessage = function(req, res, next){
group.markModified('chat');
group.save(function(err,_saved){
if(err) return next(err);
if (isProd){
var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL'));
if(Array.isArray(addressesToSendTo)){
addressesToSendTo = addressesToSendTo.map(function(email){
return {email: email}
return {email: email, canSend: true}
});
}else{
addressesToSendTo = {email: addressesToSendTo}
@@ -332,7 +330,7 @@ api.flagChatMessage = function(req, res, next){
{name: "GROUP_ID", content: group._id},
{name: "GROUP_URL", content: group._id == 'habitrpg' ? (nconf.get('BASE_URL') + '/#/options/groups/tavern') : (group.type === 'guild' ? (nconf.get('BASE_URL')+ '/#/options/groups/guilds/' + group._id) : 'party')},
]);
}
return res.send(204);
});
});
@@ -556,6 +554,26 @@ api.invite = function(req, res, next) {
], function(err, results){
if (err) return next(err);
if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){
var emailVars = [
{name: 'INVITER', content: utils.getUserInfo(res.locals.user, ['name']).name}
];
if(group.type == 'guild'){
emailVars.push(
{name: 'GUILD_NAME', content: group.name},
{name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/public'}
);
}else{
emailVars.push(
{name: 'PARTY_NAME', content: group.name},
{name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'}
)
}
utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars);
}
// Have to return whole group and its members for angular to show the invited user
res.json(results[2]);
group = uuid = null;
@@ -629,7 +647,7 @@ questStart = function(req, res, next) {
var group = res.locals.group;
var force = req.query.force;
// if (group.quest.active) return res.json(400,{err:'Quest already began.'});
// if (group.quest.active) return res.json(400,{err:'Quest already began.'});
// temporarily send error email, until we know more about this issue (then remove below, uncomment above).
if (group.quest.active) return next('Quest already began.');
@@ -720,8 +738,9 @@ api.questAccept = function(req, res, next) {
if (m == user._id) {
group.quest.members[m] = true;
group.quest.leader = user._id;
} else
} else {
group.quest.members[m] = undefined;
}
});
// Party member accepting the invitation

View File

@@ -5,6 +5,8 @@ var api = module.exports;
var async = require('async');
var _ = require('lodash');
var shared = require('../../../common');
var utils = require('../utils');
var nconf = require('nconf');
var fetchMember = function(uuid, restrict){
return function(cb){
@@ -48,9 +50,11 @@ api.sendMessage = function(user, member, data){
}
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
@@ -64,6 +68,14 @@ api.sendPrivateMessage = function(req, res, next){
}
], function(err){
if (err) return sendErr(err, res, next);
if(fetchedMember.preferences.emailNotifications.newPM !== false){
utils.txnEmail(fetchedMember, 'new-pm', [
{name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name},
{name: 'PMS_INBOX_URL', content: nconf.get('BASE_URL') + '/#/options/groups/inbox'}
]);
}
res.send(200);
})
}
@@ -84,6 +96,12 @@ api.sendGift = function(req, res, next){
member.balance += amt;
user.balance -= amt;
api.sendMessage(user, member, req.body);
if(member.preferences.emailNotifications.giftedGems !== false){
utils.txnEmail(member, 'gifted-gems', [
{name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name},
{name: 'X_GEMS_GIFTED', content: req.body.gems.amount}
]);
}
return async.parallel([
function (cb2) { member.save(cb2) },
function (cb2) { user.save(cb2) }

View File

@@ -73,7 +73,15 @@ exports.createSubscription = function(data, cb) {
utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send();
}
data.user.purchased.txnCount++;
if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift);
if (data.gift){
members.sendMessage(data.user, data.gift.member, data.gift);
if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){
utils.txnEmail(member, 'gifted-subscription', [
{name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name},
{name: 'X_MONTHS_SUBSCRIPTION', content: months}
]);
}
}
async.parallel([
function(cb2){data.user.save(cb2)},
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
@@ -96,7 +104,7 @@ exports.cancelSubscription = function(data, cb) {
p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
data.user.save(cb);
if(isProduction) utils.txnEmail(data.user, 'cancel-subscription');
utils.txnEmail(data.user, 'cancel-subscription');
utils.ga.event('unsubscribe', data.paymentMethod).send();
}
@@ -110,7 +118,15 @@ exports.buyGems = function(data, cb) {
//TODO ga.transaction to reflect whether this is gift or self-purchase
utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send();
}
if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift);
if (data.gift){
members.sendMessage(data.user, data.gift.member, data.gift);
if(data.gift.member.preferences.emailNotifications.giftedGems !== false){
utils.txnEmail(member, 'gifted-gems', [
{name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name},
{name: 'X_GEMS_GIFTED', content: data.gift.gems.amount || 20}
]);
}
}
async.parallel([
function(cb2){data.user.save(cb2)},
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
@@ -137,4 +153,4 @@ exports.paypalCheckoutSuccess = paypal.executePayment;
exports.paypalIPN = paypal.ipn;
exports.iapAndroidVerify = iap.androidVerify;
exports.iapIosVerify = iap.iosVerify;
exports.iapIosVerify = iap.iosVerify;

View File

@@ -261,58 +261,36 @@ api.update = function(req, res, next) {
};
api.cron = function(req, res, next) {
try{
var user = res.locals.user,
progress = user.fns.cron(),
ranCron = user.isModified(),
quest = shared.content.quests[user.party.quest.key];
var user = res.locals.user,
progress = user.fns.cron(),
ranCron = user.isModified(),
quest = shared.content.quests[user.party.quest.key];
if (ranCron) res.locals.wasModified = true;
if (!ranCron) return next(null,user);
Group.tavernBoss(user,progress);
if (!quest) return user.save(next);
// FOR DEBUGGING, PLEASE IGNORE
var opStatus = null;
// If user is on a quest, roll for boss & player, or handle collections
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
async.waterfall([
function(cb){
opStatus = 'saveUser';
user.save(cb); // make sure to save the cron effects
},
function(saved, count, cb){
opStatus = 'runQuest';
var type = quest.boss ? 'boss' : 'collect';
Group[type+'Quest'](user,progress,cb);
},
function(){
var cb = arguments[arguments.length-1];
// User has been updated in boss-grapple, reload
User.findById(user._id, cb);
}
], function(err, saved) {
if(err) logging.loggly({
error: "Cron caught",
stack: (err.stack || err.message || err),
body: req.body, headers: req.header,
auth: req.headers['x-api-user'],
originalUrl: req.originalUrl,
opStatus: opStatus
});
res.locals.user = saved;
next(err,saved);
user = progress = quest = null;
});
}catch(e){
logging.loggly({
error: "Cron uncaught",
stack: e.stack || e
});
throw e;
}
if (ranCron) res.locals.wasModified = true;
if (!ranCron) return next(null,user);
Group.tavernBoss(user,progress);
if (!quest) return user.save(next);
// If user is on a quest, roll for boss & player, or handle collections
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
async.waterfall([
function(cb){
user.save(cb); // make sure to save the cron effects
},
function(saved, count, cb){
var type = quest.boss ? 'boss' : 'collect';
Group[type+'Quest'](user,progress,cb);
},
function(){
var cb = arguments[arguments.length-1];
// User has been updated in boss-grapple, reload
User.findById(user._id, cb);
}
], function(err, saved) {
res.locals.user = saved;
next(err,saved);
user = progress = quest = null;
});
};
// api.reroll // Shared.ops
@@ -437,16 +415,34 @@ api.cast = function(req, res, next) {
api.inviteFriends = function(req, res, next) {
Group.findOne({type:'party', members:{'$in': [res.locals.user._id]}}).select('_id name').exec(function(err,party){
if (err) return next(err);
var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name}));
_.each(req.body.emails, function(invite){
if (invite.email) {
var variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name},
{name: 'INVITEE', content: invite.name}
];
// TODO implement "users can only be invited once"
utils.txnEmail(invite, 'invite-friend', variables);
User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email}
]}).select({_id: true, 'preferences.emailNotifications': true})
.exec(function(err, userToContact){
if(err) return next(err);
var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name}));
var variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || utils.getUserInfo(res.locals.user, ['name']).name}
];
invite.canSend = true;
// We check for unsubscribeFromAll here because don't pass through utils.getUserInfo
if(!userToContact || (userToContact.preferences.emailNotifications.invitedParty !== false &&
userToContact.preferences.emailNotifications.unsubscribeFromAll !== true)){
// TODO implement "users can only be invited once"
utils.txnEmail(invite, 'invite-friend', variables);
}
});
}
});
res.send(200);
@@ -477,7 +473,7 @@ api.sessionPartyInvite = function(req,res,next){
}
/**
* All other user.ops which can easily be mapped to ../../common/scripts/index.coffee, not requiring custom API-wrapping
* All other user.ops which can easily be mapped to habitrpg-shared/index.coffee, not requiring custom API-wrapping
*/
_.each(shared.wrap({}).ops, function(op,k){
if (!api[k]) {

View File

@@ -5,6 +5,7 @@ require('winston-newrelic');
var logger, loggly;
// Currently disabled
if (nconf.get('LOGGLY:enabled')){
loggly = require('loggly').createClient({
token: nconf.get('LOGGLY:token'),

View File

@@ -75,13 +75,13 @@ module.exports.errorHandler = function(err, req, res, next) {
"\n\nbody: " + JSON.stringify(req.body) +
(res.locals.ops ? "\n\ncompleted ops: " + JSON.stringify(res.locals.ops) : "");
logging.error(stack);
logging.loggly({
/*logging.loggly({
error: "Uncaught error",
stack: (err.stack || err.message || err),
body: req.body, headers: req.header,
auth: req.headers['x-api-user'],
originalUrl: req.originalUrl
});
});*/
var message = err.message ? err.message : err;
message = (message.length < 200) ? message : message.substring(0,100) + message.substring(message.length-100,message.length);
res.json(500,{err:message}); //res.end(err.message);

View File

@@ -297,7 +297,20 @@ var UserSchema = new Schema({
advancedCollapsed: {type: Boolean, 'default': false},
toolbarCollapsed: {type:Boolean, 'default':false},
background: String,
webhooks: {type: Schema.Types.Mixed, 'default': {}}
webhooks: {type: Schema.Types.Mixed, 'default': {}},
// For this fields make sure to use strict comparison when searching for falsey values (=== false)
// As users who didn't login after these were introduced may have them undefined/null
emailNotifications: {
unsubscribeFromAll: {type: Boolean, 'default': false},
newPM: {type: Boolean, 'default': true},
wonChallenge: {type: Boolean, 'default': true},
giftedGems: {type: Boolean, 'default': true},
giftedSubscription: {type: Boolean, 'default': true},
invitedParty: {type: Boolean, 'default': true},
invitedGuild: {type: Boolean, 'default': true},
//remindersToLogin: {type: Boolean, 'default': true},
importantAnnouncements: {type: Boolean, 'default': true}
}
},
profile: {
blurb: String,

View File

@@ -8,9 +8,11 @@ auth.setupPassport(router); //FIXME make this consistent with the others
router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser);
router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal);
router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial);
router.delete('/api/v2/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial);
router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword);
router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword);
router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername);
router.post('/api/v2/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail);
router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser);
router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal);

View File

@@ -4,6 +4,9 @@ var crypto = require('crypto');
var path = require("path");
var request = require('request');
// Set when utils.setupConfig is run
var isProd, baseUrl;
module.exports.ga = undefined; // set Google Analytics on nconf init
module.exports.sendEmail = function(mailData) {
@@ -22,48 +25,75 @@ module.exports.sendEmail = function(mailData) {
});
}
function getMailingInfo(user) {
var email, name;
if(user.auth.local && user.auth.local.email){
email = user.auth.local.email;
name = user.profile.name || user.auth.local.username;
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
email = user.auth.facebook.emails[0].value;
name = user.auth.facebook.displayName || user.auth.facebook.username;
function getUserInfo(user, fields) {
var info = {};
if(fields.indexOf('name') != -1){
if(user.auth.local){
info.name = user.profile.name || user.auth.local.username;
}else if(user.auth.facebook){
info.name = user.auth.facebook.displayName || user.auth.facebook.username;
}
}
return {email: email, name: name};
if(fields.indexOf('email') != -1){
if(user.auth.local){
info.email = user.auth.local.email;
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
info.email = user.auth.facebook.emails[0].value;
}
}
if(fields.indexOf('canSend') != -1){
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
}
return info;
}
module.exports.getUserInfo = getUserInfo;
module.exports.txnEmail = function(mailingInfoArray, emailType, variables){
var variables = [{name: 'BASE_URL', content: nconf.get('BASE_URL')}].concat(variables || []);
var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
var variables = [
{name: 'BASE_URL', content: baseUrl},
{name: 'EMAIL_SETTINGS_URL', content: baseUrl + '/#/options/settings/notifications'}
].concat(variables || []);
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
mailingInfoArray = mailingInfoArray.map(function(mailingInfo){
return mailingInfo._id ? getMailingInfo(mailingInfo) : mailingInfo;
return mailingInfo._id ? getUserInfo(mailingInfo, ['email', 'name', 'canSend']) : mailingInfo;
}).filter(function(mailingInfo){
return mailingInfo.email ? true : false;
return (mailingInfo.email && mailingInfo.canSend);
});
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
// When only one recipient send his info as variables
if(mailingInfoArray.length === 1 && mailingInfoArray[0].name){
variables.push({name: 'RECIPIENT_NAME', content: mailingInfoArray[0].name});
}
if(isProd && mailingInfoArray.length > 0){
request({
url: nconf.get('EMAIL_SERVER:url') + '/job',
method: 'POST',
auth: {
user: nconf.get('EMAIL_SERVER:authUser'),
pass: nconf.get('EMAIL_SERVER:authPassword')
},
options: {
attemps: 5,
backoff: {delay: 10*60*1000, type: 'fixed'}
json: {
type: 'email',
data: {
emailType: emailType,
to: mailingInfoArray,
variables: variables
},
options: {
attemps: 5,
backoff: {delay: 10*60*1000, type: 'fixed'}
}
}
}
});
});
}
}
// Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/
@@ -93,6 +123,9 @@ module.exports.setupConfig = function(){
if (nconf.get('NODE_ENV') === 'production')
require('newrelic');
isProd = nconf.get('NODE_ENV') === 'production';
baseUrl = nconf.get('BASE_URL');
module.exports.ga = require('universal-analytics')(nconf.get('GA_ID'));
};

View File

@@ -23,14 +23,14 @@ script(type='text/ng-template', id='partials/options.inventory.seasonalshop.html
.container-fluid
.stable.row
.col-md-2
.seasonalshop_winter2015
.seasonalshop_closed
.col-md-10
.popover.static-popover.fade.right.in
.arrow
h3.popover-title!=env.t('seasonalShopTitle', {linkStart:"<a href='http://blog.habitrpg.com/who' target='_blank'>", linkEnd: "</a>"})
h3.popover-title!=env.t('seasonalShopClosedTitle', {linkStart:"<a href='http://blog.habitrpg.com/who' target='_blank'>", linkEnd: "</a>"})
.popover-content
p!=env.t('seasonalShopText')
br
p!=env.t('seasonalShopClosedText', {linkStart:"<a href='http://habitrpg.wikia.com/wiki/Grand_Galas' target='_blank'>", linkEnd: "</a>"})
// br
.well(ng-if='User.user.achievements.rebirths > 0')=env.t('seasonalShopRebirth')
li.customize-menu.inventory-gear
menu.pets-menu(label='{{::label}}', ng-repeat='(set,label) in ::{candycane:env.t("candycaneSet"), ski:env.t("skiSet"), snowflake:env.t("snowflakeSet"), yeti:env.t("yetiSet")}')

View File

@@ -63,7 +63,7 @@ mixin customizeProfile(mobile)
button(type='button', ng-if='user.purchased.hair.color.#{color}', class='customize-option hair hair_bangs_1_#{color}', ng-click='unlock("hair.color.#{color}")')
+buyPref('hair.color', ['rainbow','yellow','green','purple','blue','TRUred'], 'rainbowColors')
+buyPref('hair.color', ['candycorn','ghostwhite','halloween','midnight','pumpkin','zombie'], 'hauntedColors', 'disabled')
+buyPref('hair.color', ['aurora','festive','hollygreen','peppermint','snowy','winterstar'], 'winteryColors')
+buyPref('hair.color', ['aurora','festive','hollygreen','peppermint','snowy','winterstar'], 'winteryColors', 'disabled')
li.customize-menu
menu(label=env.t('bodyHair'))

View File

@@ -14,6 +14,8 @@ script(id='partials/options.settings.html', type="text/ng-template")
=env.t('coupon')
li(ng-class="{ active: $state.includes('options.settings.subscription') }")
a(ui-sref='options.settings.subscription')=env.t('subscription')
li(ng-class="{ active: $state.includes('options.settings.notifications') }")
a(ui-sref='options.settings.notifications')=env.t('notifications')
.tab-content
.tab-pane.active
@@ -84,11 +86,29 @@ script(type='text/ng-template', id='partials/options.settings.settings.html')
|&nbsp;
=env.t('subWarning3')
.personal-options.col-md-6
.panel.panel-default
.panel-heading
span Registration
.panel-body
p(ng-if='user.auth.facebook.id')=env.t('registeredWithFb')
div(ng-if='user.auth.facebook.id')
button.btn.btn-primary(disabled='disabled', ng-if='!user.auth.local.username')=env.t('registeredWithFb')
button.btn.btn-danger(ng-click='http("delete","/api/v2/user/auth/social",null,"detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook')
hr
div(ng-if='!user.auth.local.username')
p Add local authentication:
form(ng-submit='http("post","/api/v2/register",localAuth,"addedLocalAuth")', ng-init='localAuth={}', name='localAuth', novalidate)
//-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll')
.form-group
input.form-control(type='text', placeholder=env.t('username'), ng-model='localAuth.username', required)
.form-group
input.form-control(type='text', placeholder=env.t('email'), ng-model='localAuth.email', required)
.form-group
input.form-control(type='password', placeholder=env.t('password'), ng-model='localAuth.password', required)
.form-group
input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='localAuth.confirmPassword', required)
input.btn.btn-default(type='submit', ng-disabled='localAuth.$invalid', value=env.t('submit'))
div(ng-if='user.auth.local.username')
p=env.t('username')
|: {{user.auth.local.username}}
@@ -100,31 +120,35 @@ script(type='text/ng-template', id='partials/options.settings.settings.html')
|&nbsp;
=env.t('loginNameDescription3')
p=env.t('email')
|: {{::user.auth.local.email}}
p
small.muted
=env.t('emailChange1')
|&nbsp;
a(href='mailto:admin@habitrpg.com')=env.t('emailChange2')
|&nbsp;
=env.t('emailChange3')
|: {{user.auth.local.email}}
hr
h5=env.t('changeUsername')
form(ng-submit='changeUsername(changeUser)', ng-show='user.auth.local')
form(ng-submit='changeUser("username", usernameUpdates)', ng-init='usernameUpdates={}', ng-show='user.auth.local', name='changeUsername', novalidate)
//-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll')
.form-group
input.form-control(type='text', placeholder=env.t('newUsername'), ng-model='changeUser.newUsername', required)
input.form-control(type='text', placeholder=env.t('newUsername'), ng-model='usernameUpdates.username', required)
.form-group
input.form-control(type='password', placeholder=env.t('password'), ng-model='changeUser.password', required)
input.btn.btn-default(type='submit', value=env.t('submit'))
input.form-control(type='password', placeholder=env.t('password'), ng-model='usernameUpdates.password', required)
input.btn.btn-default(type='submit', ng-disabled='changeUsername.$invalid', value=env.t('submit'))
h5=env.t('changeEmail')
form(ng-submit='changeUser("email", emailUpdates)', ng-show='user.auth.local', name='changeEmail', novalidate)
.form-group
input.form-control(type='text', placeholder=env.t('newEmail'), ng-model='emailUpdates.email', required)
.form-group
input.form-control(type='password', placeholder=env.t('password'), ng-model='emailUpdates.password', required)
input.btn.btn-default(type='submit', ng-disabled='changeEmail.$invalid', value=env.t('submit'))
h5=env.t('changePass')
form(ng-submit='changePassword(changePass)', ng-show='user.auth.local')
form(ng-submit='changeUser("password", passwordUpdates)', ng-show='user.auth.local', name='changePassword', novalidate)
.form-group
input.form-control(type='password', placeholder=env.t('oldPass'), ng-model='changePass.oldPassword', required)
input.form-control(type='password', placeholder=env.t('oldPass'), ng-model='passwordUpdates.oldPassword', required)
.form-group
input.form-control(type='password', placeholder=env.t('newPass'), ng-model='changePass.newPassword', required)
input.form-control(type='password', placeholder=env.t('newPass'), ng-model='passwordUpdates.newPassword', required)
.form-group
input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='changePass.confirmNewPassword', required)
input.btn.btn-default(type='submit', value=env.t('submit'))
input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='passwordUpdates.confirmNewPassword', required)
input.btn.btn-default(type='submit', ng-disabled='changePassword.$invalid', value=env.t('submit'))
.panel.panel-default
@@ -242,6 +266,64 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template')
input.focusable(type='checkbox', checked)
label
script(id='partials/options.settings.notifications.html', type="text/ng-template")
.container-fluid
.row
.personal-options.col-md-6
.panel.panel-default
.panel-heading
=env.t('emailNotifications')
.panel-body
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.newPM', ng-change='set({"preferences.emailNotifications.newPM": user.preferences.emailNotifications.newPM ? true: false})')
span=env.t('newPM')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.wonChallenge', ng-change='set({"preferences.emailNotifications.wonChallenge": user.preferences.emailNotifications.wonChallenge ? true: false})')
span=env.t('wonChallenge')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedGems', ng-change='set({"preferences.emailNotifications.giftedGems": user.preferences.emailNotifications.giftedGems ? true: false})')
span=env.t('giftedGems')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedSubscription', ng-change='set({"preferences.emailNotifications.giftedSubscription": user.preferences.emailNotifications.giftedSubscription ? true: false})')
span=env.t('giftedSubscription')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedParty', ng-change='set({"preferences.emailNotifications.invitedParty": user.preferences.emailNotifications.invitedParty ? true: false})')
span=env.t('invitedParty')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedGuild', ng-change='set({"preferences.emailNotifications.invitedGuild": user.preferences.emailNotifications.invitedGuild ? true: false})')
span=env.t('invitedGuild')
//.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.remindersToLogin', ng-change='set({"preferences.emailNotifications.remindersToLogin": user.preferences.emailNotifications.remindersToLogin ? true: false})')
span=env.t('remindersToLogin')
.checkbox
label
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.importantAnnouncements', ng-change='set({"preferences.emailNotifications.importantAnnouncements": user.preferences.emailNotifications.importantAnnouncements ? true: false})')
span=env.t('importantAnnouncements')
hr
.checkbox
label
input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})')
span=env.t('unsubscribeAllEmails')
small=env.t('unsubscribeAllEmailsText')
script(id='partials/options.settings.subscription.html',type='text/ng-template')
//-h2=env.t('individualSub')
.container-fluid(ng-init='_subscription={key:"basic_earned"}')

View File

@@ -16,7 +16,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.detail.me
button.close(type='button', ng-click='$state.go("^")', aria-hidden='true') ×
h3 {{obj.profile.name}}
.modal-body
habitrpg-tasks(main=false)
habitrpg-tasks(main=false, modal='true')
.modal-footer
a.btn.btn-default(ng-click='$state.go("^")')=env.t('close')

View File

@@ -242,6 +242,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}')
a(ui-sref='options.settings.coupon') Coupon
li
a(ui-sref='options.settings.subscription')=env.t('subscription')
li
a(ui-sref='options.settings.notifications')=env.t('notifications')
ul.toolbar-submenu(ng-click='expandMenu(null)')
li
a(href="http://habitrpg.wikia.com/wiki/FAQ", target='_blank')=env.t('FAQ')

View File

@@ -32,9 +32,9 @@ script(type='text/ng-template', id='modals/member.html')
include ../profiles/achievements
.modal-footer
.btn-group.pull-left(ng-if='::user')
button.btn.btn-md.btn-default(ng-show='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right')
button.btn.btn-md.btn-default(ng-if='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right')
span.glyphicon.glyphicon-plus
button.btn.btn-md.btn-default(ng-hide='profile._id == user._id || user.inbox.blocks | contains:profile._id', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right')
button.btn.btn-md.btn-default(ng-if='profile._id != user._id && !profile.contributor.admin && !(user.inbox.blocks | contains:profile._id)', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right')
span.glyphicon.glyphicon-ban-circle
button.btn.btn-md.btn-default(tooltip=env.t('sendPM'), ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='right')
span.glyphicon.glyphicon-envelope

View File

@@ -1,31 +1,54 @@
h5 1/30/2015 - HABITRPG BIRTHDAY BASH AND PARTY ROBES! PLUS, LAST CHANCE FOR STARRY KNIGHT ITEM SET, AND WINTER WONDERLAND OUTFITS AND HAIR COLORS!
h5 2/3/2015
hr
tr
td
.npc_alex.pull-left
h5 HabitRPG Birthday Bash
p January 31st is HabitRPG's Birthday! All of the NPCs are celebrating, and we've awarded you a bunch of cake for your pets and mounts!
tr
td
h5 Party Robes
.shop_armor_special_birthday.pull-right
.shop_armor_special_birthday2015.pull-right
p Until February 1st only, there are Party Robes available for free in the Rewards store! If this is your first Birthday bash with us, you can find some Absurd Party Robes; if you already got some last year, then you will find the Silly Party Robes.
tr
td
.promo_mystery_201501.pull-left
h5 Last Chance for Starry Knight Item Set
p Reminder: this is the final day to <a href='https://habitrpg.com/#/options/settings/subscription' target='_blank'>subscribe</a> and receive the Starry Knight Item Set! If you want the Starry Helm or the Starry Armor, now's the time! Thanks so much for your support <3
tr
td
h5 Last Chance for Winter Wonderland Outfits + Hair Colors
.promo_winterclasses2015.pull-right
p Tomorrow everything will be back to normal in Habitica, so if you still have any remaining Winter Wonderland Items that you want to buy, you'd better do it now! The <a href='https://habitrpg.com/#/options/inventory/seasonalshop' target='_blank'>Seasonal Edition items</a> and <a href='https://habitrpg.com/#/options/profile/avatar' target='_blank'>Hair Colors</a> won't be back until next December, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot!
h5 FEBRUARY BACKGROUNDS REVEALED
.background_distant_castle.pull-right
p There are three new avatar backgrounds in the <a href='https://habitrpg.com/#/options/profile/backgrounds' target='_blank'>Background Shop</a>! Now your avatar can survey a Distant Castle, toil in the Blacksmithy, or explore a Crystal Cave!
p.small.muted by Holseties, Hanztan, and Twitching
hr
a(href='/static/old-news', target='_blank') Read older news
mixin oldNews
h5 2/2/2015
tr
td
h5 February Mystery Box
.inventory_present.pull-right
p Ooh... What could it be? All Habiticans who are subscribed during the month of February will receive the February Mystery Item Set! It will be revealed on the 24th, so keep your eyes peeled. Thanks for supporting the site <3
p.small.muted by Lemoness
tr
td
h5 New Quest Descriptions
p We've updated quest descriptions so that when you hover over them, you can now see the Boss or Collection stats and the Rewards that you will gain when you complete the quest!
p.small.muted by Blade
tr
td
h5 Spread the Word Challenge Has Ended
p The Spread the Word Challenge has ended! Thank you to all the participants. It will be some time before the winners are announced because we have to go over all the entries ourselves. Thanks for your patience!
h5 1/30/2015
tr
td
.npc_alex.pull-left
h5 HabitRPG Birthday Bash
p January 31st is HabitRPG's Birthday! All of the NPCs are celebrating, and we've awarded you a bunch of cake for your pets and mounts!
tr
td
h5 Party Robes
.shop_armor_special_birthday.pull-right
.shop_armor_special_birthday2015.pull-right
p Until February 1st only, there are Party Robes available for free in the Rewards store! If this is your first Birthday bash with us, you can find some Absurd Party Robes; if you already got some last year, then you will find the Silly Party Robes.
tr
td
.promo_mystery_201501.pull-left
h5 Last Chance for Starry Knight Item Set
p Reminder: this is the final day to <a href='https://habitrpg.com/#/options/settings/subscription' target='_blank'>subscribe</a> and receive the Starry Knight Item Set! If you want the Starry Helm or the Starry Armor, now's the time! Thanks so much for your support <3
tr
td
h5 Last Chance for Winter Wonderland Outfits + Hair Colors
.promo_winterclasses2015.pull-right
p Tomorrow everything will be back to normal in Habitica, so if you still have any remaining Winter Wonderland Items that you want to buy, you'd better do it now! The <a href='https://habitrpg.com/#/options/inventory/seasonalshop' target='_blank'>Seasonal Edition items</a> and <a href='https://habitrpg.com/#/options/profile/avatar' target='_blank'>Hair Colors</a> won't be back until next December, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot!
h5 1/26/2015
tr
td

View File

@@ -1,4 +1,4 @@
li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s"]', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-class='{"cast-target":spell && (list.type != "reward")}', popover-trigger='mouseenter', data-popover-html="{{task.notes | markdown}}", popover-placement="top", popover-append-to-body='true', ng-show='shouldShow(task, list, user.preferences)')
li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s"]', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-class='{"cast-target":spell && (list.type != "reward")}', popover-trigger='mouseenter', data-popover-html="{{task.notes | markdown}}", popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}', ng-show='shouldShow(task, list, user.preferences)')
// right-hand side control buttons
.task-meta-controls