Compare commits

...

37 Commits

Author SHA1 Message Date
Sabe Jones d89b1e3b08 chore(patch): Bump version to 3.4.2 2016-05-30 18:07:32 +00:00
Sabe Jones 8520987afb chore(news): May Last Chance Bailey 2016-05-30 17:42:59 +00:00
Blade Barringer c327baad41 Merge pull request #7538 from crookedneighbor/fix_invite_self_to_party
fix: Prevent user from inivting self to group
2016-05-29 10:37:48 -05:00
Blade Barringer bbafe3d52d fix: Prevent user from inivting self to group
fixes #7491
2016-05-29 10:09:02 -05:00
Blade Barringer d23f79d001 fix: Correct typo in tag documentation
Per https://twitter.com/Rio_Alx/status/736109816964227073
2016-05-29 09:24:33 -05:00
Blade Barringer db3fd62af8 fix: correct link in issue template 2016-05-29 07:34:59 -05:00
hairlessbear 00aebed8c0 Use npm 3 in Dockerfile (#7529) 2016-05-29 09:08:14 +10:00
velezsarain 4ad81c4c28 Change install_node.sh to install npm 3 instead. Default RAM for vagrant box is now 4 GB. (#7478) 2016-05-28 11:53:39 +10:00
Matteo Pagliazzi 36042d42f9 disable loggly config while testing 2016-05-27 17:17:00 +02:00
Matteo Pagliazzi 7090ed5f4b update elastic beanstalk config to install loggly 2016-05-27 17:01:25 +02:00
Matteo Pagliazzi 2d81b31c3a remove legacy loggly code 2016-05-27 16:38:55 +02:00
Matteo Pagliazzi 01a7880bc9 add migration to fix empty checklist.id 2016-05-27 15:14:27 +02:00
Matteo Pagliazzi 20ce394999 chore: Bump version to 3.4.1 2016-05-27 13:24:17 +02:00
Matteo Pagliazzi 3ce80bae2a make checklist.id required 2016-05-27 13:17:05 +02:00
Matteo Pagliazzi 4deb5ada2f add timestamps to logs 2016-05-27 11:29:46 +02:00
Matteo Pagliazzi 39a9753fd6 chore: Bump version to 3.4.0 2016-05-27 11:05:12 +02:00
Matteo Pagliazzi a9d4bbd87d dataexport: correctly handle 403 errors 2016-05-27 10:53:20 +02:00
Blade Barringer c08df9978f Merge pull request #7511 from HabitRPG/paglias/better-logging
Improve logging
2016-05-26 22:42:14 -05:00
Blade Barringer 74087d23c9 Merge branch 'HabitRPG-paglias/fix-double-cron' into develop 2016-05-26 22:40:32 -05:00
Blade Barringer b40d8ce09d Merge branch 'double_cron_fix' into develop 2016-05-26 22:25:36 -05:00
Blade Barringer 91aba965b0 tests: Add tests for recoverCron 2016-05-26 22:08:31 -05:00
Sabe Jones 9b7458c022 chore(patch): bump version to 3.3.3 2016-05-27 01:20:00 +00:00
Sabe Jones 31d2c5d604 fix(test): import Bluebird 2016-05-27 00:59:00 +00:00
Matteo Pagliazzi cb4676980d fix tests for error handler middleware 2016-05-26 23:35:57 +02:00
Matteo Pagliazzi 10cf22cd4e better distinguish between handled and not handler errors 2016-05-26 23:19:29 +02:00
Matteo Pagliazzi c6192dd24b feat(logging): improve logging removing not useful data and distinguishing between user and server errors 2016-05-26 23:10:38 +02:00
Sabe Jones 792aafd737 fix(test): delay for task creation 2016-05-26 20:51:55 +00:00
Matteo Pagliazzi 0ca44fd5c2 fix tests when transferring gems 2016-05-26 22:45:12 +02:00
Matteo Pagliazzi 457264faa1 remove console.log used for debugging 2016-05-26 22:11:48 +02:00
Matteo Pagliazzi 3c338ddcd7 simplify tests and add console.log for debugging 2016-05-26 21:54:26 +02:00
Matteo Pagliazzi 8292903444 fix user anonymized tests 2016-05-25 23:33:29 +02:00
Matteo Pagliazzi 275d29e3f4 run cron when getting group 2016-05-25 23:14:34 +02:00
Matteo Pagliazzi 9b529fff52 cleanup quests code 2016-05-25 21:53:44 +02:00
Matteo Pagliazzi f9762f4f81 fix cron tests 2016-05-25 21:01:12 +02:00
Matteo Pagliazzi ed815d4947 refactor recovering from cron 2016-05-25 19:58:18 +02:00
Matteo Pagliazzi 31850830a0 refactor cron to avoid double cronning - work in progress 2016-05-25 16:55:57 +02:00
Matteo Pagliazzi 1a26965542 wip(): prevent cron from running twice 2016-05-25 15:31:18 +02:00
27 changed files with 542 additions and 252 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
[//]: # (Before logging this issue, look through common problems at https://github.com/HabitRPG/habitrpg/issue If you find your issue there, read at least the first post to see if there is a workaround for you)
[//]: # (Before logging this issue, look through common problems at https://github.com/HabitRPG/habitrpg/issues If you find your issue there, read at least the first post to see if there is a workaround for you)
[//]: # (Github is primarily used for reporting bugs. If you have a feature request, use "Help > Request a Feature" so that the feature request can be vetted by the larger Habitica community)
+1 -1
View File
@@ -25,7 +25,7 @@ RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
# Install global packages
RUN npm install -g gulp grunt-cli bower
RUN npm install -g gulp grunt-cli bower npm@3
# Clone Habitica repo and install dependencies
WORKDIR /habitrpg
+1 -1
View File
@@ -6,7 +6,7 @@ VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.provider "virtualbox" do |v|
v.memory = 768
v.memory = 4096
v.cpus = 1
v.customize ["setextradata", :id, "VBoxInternal2/SharedFoldersEnableSymlinksCreate/vagrant", "1"]
end
+1
View File
@@ -177,6 +177,7 @@
"inviteMissingEmail": "Missing email address in invite.",
"partyMustbePrivate": "Parties must be private",
"userAlreadyInGroup": "User already in that group.",
"cannotInviteSelfToGroup": "You cannot invite yourself to a group.",
"userAlreadyInvitedToGroup": "User already invited to that group.",
"userAlreadyPendingInvitation": "User already pending invitation.",
"userAlreadyInAParty": "User already in a party.",
+2 -7
View File
@@ -61,13 +61,8 @@
"client_secret":"client_secret"
},
"IAP_GOOGLE_KEYDIR": "/path/to/google/public/key/dir/",
"LOGGLY": {
"enabled": false,
"subdomain": "subdomain",
"token": "token",
"username": "username",
"password": "password"
},
"LOGGLY_TOKEN": "token",
"LOGGLY_ACCOUNT": "account",
"PUSH_CONFIGS": {
"GCM_SERVER_API_KEY": "",
"APN_PEM_FILES": {
@@ -0,0 +1,78 @@
var uuid = require('uuid').v4;
var mongo = require('mongodb').MongoClient;
var _ = require('lodash');
var taskIds = require('checklists-no-id.json').map(function (obj) {
return obj._id;
});
// Fix empty task.checklistt.id
var progressCount = 100;
var count = 0;
function displayData() {
console.warn('\n' + count + ' tasks 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); }
}
}
mongo.connect('db url')
.then(function (db) {
var dbTasks = db.collection('tasks');
// specify a query to limit the affected tasks (empty for all tasks):
var query = {
'_id':{ $in: taskIds },
};
// specify fields we are interested in to limit retrieved data (empty if we're not reading data):
var fields = {
'checklist': 1,
};
console.warn('Updating tasks...');
dbTasks.find(query, fields, {batchSize: 250}).toArray(function(err, tasks) {
if (err) { return exiting(1, 'ERROR! ' + err); }
tasks.forEach(function (task) {
var checklist = task.checklist || [];
checklist.forEach(function (item) {
if (!item.id || item.id === "") {
item.id = uuid();
}
});
// specify user data to change:
var set = {
checklist: checklist,
};
//console.log(set);
dbTasks.update({_id: task._id}, {$set: set}, function (err, res) {
if (err) console.error('Error while updating', err);
});
count++;
if (count % progressCount == 0) console.warn(count + ' ' + task._id);
});
if (count === tasks.length) {
console.warn('All appropriate tasks found and modified.');
return displayData();
}
});
})
.catch(function (err) {
throw err;
});
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "3.3.2",
"version": "3.4.2",
"main": "./website/server/index.js",
"dependencies": {
"accepts": "^1.3.2",
@@ -59,7 +59,6 @@
"js2xmlparser": "~1.0.0",
"lodash": "^3.10.1",
"lodash.setwith": "^4.2.0",
"loggly": "~1.0.8",
"markdown-it": "^6.0.1",
"merge-stream": "^1.0.0",
"method-override": "^2.3.5",
@@ -33,6 +33,17 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('returns an error when inviting yourself to a group', async () => {
await expect(inviter.post(`/groups/${group._id}/invite`, {
uuids: [inviter._id],
}))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('cannotInviteSelfToGroup'),
});
});
it('returns an error when uuids is not an array', async () => {
let fakeID = generateUUID();
@@ -3,6 +3,7 @@ import {
generateGroup,
generateChallenge,
} from '../../../../../helpers/api-integration/v3';
import Bluebird from 'bluebird';
import { find } from 'lodash';
describe('POST /tasks/:id/score/:direction', () => {
@@ -26,6 +27,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test habit',
type: 'habit',
});
await Bluebird.delay(1000);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.habits[0];
});
@@ -63,6 +65,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test daily',
type: 'daily',
});
await Bluebird.delay(1000);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.dailys[0];
});
@@ -99,6 +102,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test todo',
type: 'todo',
});
await Bluebird.delay(1000);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.todos[0];
});
@@ -123,6 +127,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test reward',
type: 'reward',
});
await Bluebird.delay(1000);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.todos[0];
});
@@ -13,7 +13,7 @@ describe('GET /user/anonymized', () => {
before(async () => {
user = await generateUser();
await user.update({ newMessages: ['some', 'new', 'messages'], profile: 'profile', 'purchased.plan': 'purchased plan',
await user.update({ newMessages: ['some', 'new', 'messages'], 'profile.name': 'profile', 'purchased.plan': 'purchased plan',
contributor: 'contributor', invitations: 'invitations', 'items.special.nyeReceived': 'some', 'items.special.valentineReceived': 'some',
webhooks: 'some', 'achievements.challenges': 'some',
'inbox.messages': [{ text: 'some text' }],
+67 -10
View File
@@ -1,6 +1,7 @@
/* eslint-disable global-require */
import moment from 'moment';
import { cron } from '../../../../../website/server/libs/api-v3/cron';
import Bluebird from 'bluebird';
import { recoverCron, cron } from '../../../../../website/server/libs/api-v3/cron';
import { model as User } from '../../../../../website/server/models/user';
import * as Tasks from '../../../../../website/server/models/task';
import { clone } from 'lodash';
@@ -34,15 +35,6 @@ describe('cron', () => {
};
});
it('updates user.auth.timestamps.loggedin and lastCron', () => {
let now = new Date();
cron({user, tasksByType, daysMissed, analytics, now});
expect(user.auth.timestamps.loggedin).to.equal(now);
expect(user.lastCron).to.equal(now);
});
it('updates user.preferences.timezoneOffsetAtLastCron', () => {
let timezoneOffsetFromUserPrefs = 1;
@@ -571,3 +563,68 @@ describe('cron', () => {
});
});
});
describe('recoverCron', () => {
let locals, status, execStub;
beforeEach(() => {
execStub = sandbox.stub();
sandbox.stub(User, 'findOne').returns({ exec: execStub });
status = { times: 0 };
locals = {
user: new User({
auth: {
local: {
username: 'username',
lowerCaseUsername: 'username',
email: 'email@email.email',
salt: 'salt',
hashed_password: 'hashed_password', // eslint-disable-line camelcase
},
},
}),
};
});
afterEach(() => {
sandbox.restore();
});
it('throws an error if user cannot be found', async (done) => {
execStub.returns(Bluebird.resolve(null));
try {
await recoverCron(status, locals);
} catch (err) {
expect(err.message).to.eql(`User ${locals.user._id} not found while recovering.`);
done();
}
});
it('increases status.times count and reruns up to 3 times', async (done) => {
execStub.returns(Bluebird.resolve({_cronSignature: 'RUNNING_CRON'}));
execStub.onCall(3).returns(Bluebird.resolve({_cronSignature: 'NOT_RUNNING'}));
await recoverCron(status, locals);
expect(status.times).to.eql(3);
expect(locals.user).to.eql({_cronSignature: 'NOT_RUNNING'});
done();
});
it('throws an error if recoverCron runs 4 times', async (done) => {
execStub.returns(Bluebird.resolve({_cronSignature: 'RUNNING_CRON'}));
try {
await recoverCron(status, locals);
} catch (err) {
expect(status.times).to.eql(4);
expect(err.message).to.eql(`Impossible to recover from cron for user ${locals.user._id}.`);
done();
}
});
});
+91 -44
View File
@@ -1,26 +1,26 @@
import {
generateRes,
generateReq,
generateNext,
generateTodo,
generateDaily,
} from '../../../../helpers/api-unit.helper';
import { cloneDeep } from 'lodash';
import cronMiddleware from '../../../../../website/server/middlewares/api-v3/cron';
import moment from 'moment';
import { model as User } from '../../../../../website/server/models/user';
import { model as Group } from '../../../../../website/server/models/group';
import * as Tasks from '../../../../../website/server/models/task';
import analyticsService from '../../../../../website/server/libs/api-v3/analyticsService';
import * as cronLib from '../../../../../website/server/libs/api-v3/cron';
import { v4 as generateUUID } from 'uuid';
describe('cron middleware', () => {
let res, req, next;
let res, req;
let user;
beforeEach(() => {
beforeEach((done) => {
res = generateRes();
req = generateReq();
next = generateNext();
user = new User({
auth: {
local: {
@@ -33,24 +33,31 @@ describe('cron middleware', () => {
},
});
user._statsComputed = {
mp: 10,
maxMP: 100,
};
user.save()
.then(savedUser => {
savedUser._statsComputed = {
mp: 10,
maxMP: 100,
};
res.locals.user = user;
res.analytics = analyticsService;
res.locals.user = savedUser;
res.analytics = analyticsService;
done();
})
.catch(done);
});
it('calls next when user is not attached', () => {
afterEach(() => {
sandbox.restore();
});
it('calls next when user is not attached', (done) => {
res.locals.user = null;
cronMiddleware(req, res, next);
expect(next).to.be.calledOnce;
cronMiddleware(req, res, done);
});
it('calls next when days have not been missed', () => {
cronMiddleware(req, res, next);
expect(next).to.be.calledOnce;
it('calls next when days have not been missed', (done) => {
cronMiddleware(req, res, done);
});
it('should clear todos older than 30 days for free users', async (done) => {
@@ -59,35 +66,37 @@ describe('cron middleware', () => {
task.dateCompleted = moment(new Date()).subtract({days: 31});
task.completed = true;
await task.save();
await user.save();
cronMiddleware(req, res, () => {
Tasks.Task.findOne({_id: task}, function (err, taskFound) {
expect(err).to.not.exist;
cronMiddleware(req, res, (err) => {
Tasks.Task.findOne({_id: task}, function (secondErr, taskFound) {
expect(secondErr).to.not.exist;
expect(taskFound).to.not.exist;
done();
done(err);
});
});
});
it('should not clear todos older than 30 days for subscribed users', (done) => {
it('should not clear todos older than 30 days for subscribed users', async (done) => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({days: 2});
let task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({days: 31});
task.completed = true;
task.save();
await task.save();
await user.save();
cronMiddleware(req, res, () => {
Tasks.Task.findOne({_id: task}, function (err, taskFound) {
expect(err).to.not.exist;
cronMiddleware(req, res, (err) => {
Tasks.Task.findOne({_id: task}, function (secondErr, taskFound) {
expect(secondErr).to.not.exist;
expect(taskFound).to.exist;
done();
done(err);
});
});
});
it('should clear todos older than 90 days for subscribed users', (done) => {
it('should clear todos older than 90 days for subscribed users', async (done) => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({days: 2});
@@ -95,46 +104,60 @@ describe('cron middleware', () => {
let task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({days: 91});
task.completed = true;
task.save();
await task.save();
await user.save();
cronMiddleware(req, res, () => {
Tasks.Task.findOne({_id: task}, function (err, taskFound) {
expect(err).to.not.exist;
cronMiddleware(req, res, (err) => {
Tasks.Task.findOne({_id: task}, function (secondErr, taskFound) {
expect(secondErr).to.not.exist;
expect(taskFound).to.not.exist;
done();
done(err);
});
});
});
it('should call next is user was not modified after cron', (done) => {
it('should call next if user was not modified after cron', async (done) => {
let hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({days: 2});
await user.save();
user.save().then(function () {
cronMiddleware(req, res, function () {
expect(hpBefore).to.equal(user.stats.hp);
done();
});
cronMiddleware(req, res, (err) => {
expect(hpBefore).to.equal(user.stats.hp);
done(err);
});
});
it('does damage for missing dailies', (done) => {
it('updates user.auth.timestamps.loggedin and lastCron', async (done) => {
user.lastCron = moment(new Date()).subtract({days: 2});
let now = new Date();
await user.save();
cronMiddleware(req, res, (err) => {
expect(moment(now).isSame(user.lastCron, 'day'));
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
done(err);
});
});
it('does damage for missing dailies', async (done) => {
let hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({days: 2});
let daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({days: 2});
daily.save();
await daily.save();
await user.save();
cronMiddleware(req, res, () => {
cronMiddleware(req, res, (err) => {
expect(user.stats.hp).to.be.lessThan(hpBefore);
done();
done(err);
});
});
it('updates tasks', (done) => {
it('updates tasks', async (done) => {
user.lastCron = moment(new Date()).subtract({days: 2});
let todo = generateTodo(user);
let todoValueBefore = todo.value;
await user.save();
cronMiddleware(req, res, () => {
Tasks.Task.findOne({_id: todo._id}, function (err, todoFound) {
@@ -150,7 +173,7 @@ describe('cron middleware', () => {
user.lastCron = moment(new Date()).subtract({days: 2});
let daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({days: 2});
daily.save();
await daily.save();
let questKey = 'dilatory';
user.party.quest.key = questKey;
@@ -174,4 +197,28 @@ describe('cron middleware', () => {
done();
});
});
it('recovers from failed cron and does not error when user is already cronning', async (done) => {
user.lastCron = moment(new Date()).subtract({days: 2});
await user.save();
let updatedUser = cloneDeep(user);
updatedUser.nMatched = 0;
sandbox.spy(cronLib, 'recoverCron');
sandbox.stub(User, 'update')
.withArgs({ _id: user._id, _cronSignature: 'NOT_RUNNING' })
.returns({
exec () {
return Promise.resolve(updatedUser);
},
});
cronMiddleware(req, res, () => {
expect(cronLib.recoverCron).to.be.calledOnce;
done();
});
});
});
@@ -168,6 +168,8 @@ describe('errorHandler', () => {
originalUrl: req.originalUrl,
headers: req.headers,
body: req.body,
httpCode: 400,
isHandledError: true,
});
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ nvm use
nvm alias default current
echo Update npm...
npm install -g npm@2
npm install -g npm@3
echo Installing global modules...
npm install -g gulp bower grunt-cli mocha
+2 -3
View File
@@ -117,9 +117,6 @@ api.getGroups = {
api.getGroup = {
method: 'GET',
url: '/groups/:groupId',
// Disable cron when getting groups to avoid race conditions when the site is loaded
// and requests for party and user data are concurrent
runCron: false,
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -490,6 +487,8 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
} else if (inviter._id === userToInvite._id) {
throw new BadRequest(res.t('cannotInviteSelfToGroup'));
}
if (group.type === 'guild') {
+1 -1
View File
@@ -34,7 +34,7 @@ api.createTag = {
};
/**
* @api {get} /api/v3/tag Get a user's tags
* @api {get} /api/v3/tags Get a user's tags
* @apiVersion 3.0.0
* @apiName GetTags
* @apiGroup Tag
@@ -203,7 +203,8 @@ api.exportUserAvatarPng = {
try {
response = await got.head(s3url);
} catch (gotError) {
if (gotError.code !== 'ENOTFOUND' && gotError.statusCode !== 404) {
// If the file does not exist AWS S3 can return a 403 error
if (gotError.code !== 'ENOTFOUND' && gotError.statusCode !== 404 && gotError.statusCode !== 403) {
throw gotError;
}
}
+2 -24
View File
@@ -1,24 +1,7 @@
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
});
}
var logger;
if (!logger) {
logger = new (winston.Logger)({});
@@ -49,9 +32,4 @@ module.exports.warn = function(/* variable args */) {
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);
};
};
+25 -2
View File
@@ -1,4 +1,6 @@
import moment from 'moment';
import Bluebird from 'bluebird';
import { model as User } from '../../models/user';
import common from '../../../../common/';
import { preenUserHistory } from '../../libs/api-v3/preening';
import _ from 'lodash';
@@ -81,12 +83,33 @@ function performSleepTasks (user, tasksByType, now) {
});
}
export async function recoverCron (status, locals) {
let {user} = locals;
await Bluebird.delay(300);
let reloadedUser = await User.findOne({_id: user._id}).exec();
if (!reloadedUser) {
throw new Error(`User ${user._id} not found while recovering.`);
} else if (reloadedUser._cronSignature !== 'NOT_RUNNING') {
status.times++;
if (status.times < 4) {
await recoverCron(status, locals);
} else {
throw new Error(`Impossible to recover from cron for user ${user._id}.`);
}
} else {
locals.user = reloadedUser;
return null;
}
}
// 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;
+27 -2
View File
@@ -2,6 +2,9 @@
import winston from 'winston';
import nconf from 'nconf';
import _ from 'lodash';
import {
CustomError,
} from './errors';
const IS_PROD = nconf.get('IS_PROD');
const IS_TEST = nconf.get('IS_TEST');
@@ -13,6 +16,7 @@ const logger = new winston.Logger();
if (IS_PROD) {
if (ENABLE_LOGS_IN_PROD) {
logger.add(winston.transports.Console, {
timestamp: true,
colorize: false,
prettyPrint: false,
});
@@ -20,6 +24,7 @@ if (IS_PROD) {
} else if (!IS_TEST || IS_TEST && ENABLE_LOGS_IN_TEST) { // Do not log anything when testing unless specified
logger
.add(winston.transports.Console, {
timestamp: true,
colorize: true,
prettyPrint: true,
});
@@ -42,8 +47,28 @@ let loggerInterface = {
// 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);
if (_.isPlainObject(errorData) && !errorData.fullError) {
// If the error object has interesting data (not only httpCode, message and name from the CustomError class)
// add it to the logs
if (err instanceof CustomError) {
let errWithoutCommonProps = _.omit(err, ['name', 'httpCode', 'message']);
if (Object.keys(errWithoutCommonProps).length > 0) {
errorData.fullError = errWithoutCommonProps;
}
} else {
errorData.fullError = err;
}
}
let loggerArgs = [stack, errorData, ...otherArgs];
// Treat 4xx errors that are handled as warnings, 5xx and uncaught errors as serious problems
if (!errorData || !errorData.isHandledError || errorData.httpCode >= 500) {
logger.error(...loggerArgs);
} else {
logger.warn(...loggerArgs);
}
} else {
logger.error(...args);
}
@@ -11,13 +11,6 @@ module.exports = 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({
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);
+167 -109
View File
@@ -5,108 +5,128 @@ 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';
import { recoverCron, cron } from '../../libs/api-v3/cron';
import { v4 as uuid } from 'uuid';
const daysSince = common.daysSince;
module.exports = function cronMiddleware (req, res, next) {
async function cronAsync (req, res) {
let user = res.locals.user;
if (!user) return next(); // User might not be available when authentication is not mandatory
if (!user) return null; // 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
try {
// 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;
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 (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;
}
}
if (daysMissed <= 0) return next();
// 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) {
if (user.isModified()) await user.save();
return null;
}
let _cronSignature = uuid();
// To avoid double cron we first set _cronSignature to now and then check that it's not changed while processing
let userUpdateResult = await User.update({
_id: user._id,
_cronSignature: 'NOT_RUNNING', // Check that in the meantime another cron has not started
}, {
$set: {
_cronSignature,
},
}).exec();
// If the cron signature is already set, cron is running in another request
// throw an error and recover later,
if (userUpdateResult.nMatched === 0 || userUpdateResult.nModified === 0) {
throw new Error('CRON_ALREADY_RUNNING');
}
let tasks = await Tasks.Task.find({
userId: user._id,
$or: [ // Exclude completed todos
{type: 'todo', completed: false},
{type: {$in: ['habit', 'daily', 'reward']}},
],
}).exec();
// 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));
@@ -125,11 +145,7 @@ module.exports = function cronMiddleware (req, res, next) {
'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();
res.locals.wasModified = true; // TODO remove after v2 is retired
// Group.tavernBoss(user, progress);
@@ -138,23 +154,65 @@ module.exports = function cronMiddleware (req, res, next) {
tasks.forEach(task => {
if (task.isModified()) toSave.push(task.save());
});
await Bluebird.all(toSave);
return Bluebird.all(toSave)
.then(saved => {
user = res.locals.user = saved[0];
if (!quest) return;
let quest = common.content.quests[user.party.quest.key];
if (quest) {
// 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;
await Group[`${questType}Quest`](user, progress);
}
return null;
});
// Set _cronSignature, lastCron and auth.timestamps.loggedin to signal end of cron
await User.update({
_id: user._id,
}, {
$set: {
_cronSignature: 'NOT_RUNNING',
lastCron: now,
'auth.timestamps.loggedin': now,
},
}).exec();
// Reload user
res.locals.user = await User.findOne({_id: user._id}).exec();
return null;
} catch (err) {
// If cron was aborted for a race condition try to recover from it
if (err.message === 'CRON_ALREADY_RUNNING') {
// Recovering after abort, wait 300ms and reload user
// do it for max 4 times then reset _cronSignature so that it doesn't prevent cron from running
// at the next request
let recoveryStatus = {
times: 0,
};
recoverCron(recoveryStatus, res.locals);
} else {
// For any other error make sure to reset _cronSignature so that it doesn't prevent cron from running
// at the next request
try {
await User.update({
_id: user._id,
}, {
_cronSignature: 'NOT_RUNNING',
}).exec();
throw err; // re-throw original error
} catch (secondErr) {
throw secondErr;
}
}
}
}
module.exports = function cronMiddleware (req, res, next) {
cronAsync(req, res)
.then(() => {
next();
})
.then(() => next())
.catch(next);
});
.catch(err => {
next(err);
});
};
@@ -12,12 +12,6 @@ import {
} 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
@@ -70,6 +64,15 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable
responseErr = new InternalServerError();
}
// log the error
logger.error(err, {
originalUrl: req.originalUrl,
headers: omit(req.headers, ['x-api-key', 'cookie']), // don't send sensitive information that only adds noise
body: req.body,
httpCode: responseErr.httpCode,
isHandledError: responseErr.httpCode < 500,
});
let jsonRes = {
success: false,
error: responseErr.name,
+9 -8
View File
@@ -25,6 +25,9 @@ const Schema = mongoose.Schema;
export const INVITES_LIMIT = 100;
export const TAVERN_ID = shared.TAVERN_ID;
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
// 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({
@@ -281,7 +284,7 @@ schema.methods.sendChat = function sendChat (message, user) {
this.chat.splice(200);
// Kick off chat notifications in the background.
let lastSeenUpdate = {$set: {}, $inc: {_v: 1}};
let lastSeenUpdate = {$set: {}};
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
@@ -431,7 +434,6 @@ schema.methods.finishQuest = function finishQuest (quest) {
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
@@ -498,11 +500,11 @@ schema.statics.collectQuest = async function collectQuest (user, progress) {
// 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();
})) return await group.save();
await group.finishQuest(quest);
group.sendChat('`All items found! Party has received their rewards.`');
return group.save();
return await group.save();
};
schema.statics.bossQuest = async function bossQuest (user, progress) {
@@ -517,7 +519,7 @@ schema.statics.bossQuest = async function bossQuest (user, progress) {
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' || nconf.get('CRON_SEMI_SAFE_MODE') === 'true' ? `${quest.boss.name('en')} does not attack, because it respects the fact that there are some bugs\` \`post-maintenance and it doesn't want to hurt anyone unfairly. It will continue its rampage soon!` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`;
let bossAttack = CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE ? `${quest.boss.name('en')} does not attack, because it respects the fact that there are some bugs\` \`post-maintenance and it doesn't want to hurt anyone unfairly. It will continue its rampage soon!` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`;
// TODO Consider putting the safe mode boss attack message in an ENV var
group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``);
@@ -538,7 +540,7 @@ schema.statics.bossQuest = async function bossQuest (user, progress) {
await User.update({
_id: {$in: _.keys(group.quest.members)},
}, {
$inc: {'stats.hp': down, _v: 1},
$inc: {'stats.hp': down},
}, {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
@@ -552,10 +554,9 @@ schema.statics.bossQuest = async function bossQuest (user, progress) {
// Participants: Grant rewards & achievements, finish quest
await group.finishQuest(shared.content.quests[group.quest.key]);
return group.save();
}
return group.save();
return await group.save();
};
// to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
+1 -1
View File
@@ -181,7 +181,7 @@ let dailyTodoSchema = () => {
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.']},
id: {type: String, default: shared.uuid, required: true, validate: [validator.isUUID, 'Invalid uuid.']},
}],
};
};
+2 -1
View File
@@ -345,6 +345,7 @@ export let schema = new Schema({
},
lastCron: {type: Date, default: Date.now},
_cronSignature: {type: String, default: 'NOT_RUNNING'}, // Private property used to avoid double cron
// {GROUP_ID: Boolean}, represents whether they have unseen chat messages
newMessages: {type: Schema.Types.Mixed, default: () => {
@@ -528,7 +529,7 @@ export let schema = new Schema({
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'],
private: ['auth.local.hashed_password', 'auth.local.salt', '_cronSignature'],
toJSONTransform: function userToJSON (plainObj, originalDoc) {
// plainObj.filters = {}; // TODO Not saved, remove?
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs
+32 -19
View File
@@ -1,33 +1,46 @@
h2 5/25/2016 - ANDROID UPDATE AND MAY SUBSCRIBER ITEMS
h2 5/30/2016 - LAST CHANCE FOR MARCHING BARD ITEM SET AND FLORAL POTIONS
hr
tr
td
h3 iOS App Update Available
p We've released <a href='https://itunes.apple.com/us/app/habitica-stay-motivated-gamified/id994882113' target='_blank'>a new iOS app update</a> that contains tons of bug fixes, including for the pet feeding crash and the annoying fake death popups! Be sure to download it now for a more stable Habitica.
br
p Thank you so much to everyone who reported bugs that cropped up after our massive code overhaul! It was very helpful. And if you have a spare moment, we'd love it if you could review this app. It really helps us out! iOS hides all old reviews, but if you've already written one, it's very easy to repost with a single button click.
p.small.muted by viirus
tr
td
h3 Android Update: Bug Fixes
p We've released <a href='https://play.google.com/store/apps/details?id=com.habitrpg.android.habitica&hl=en' target='_blank'>a new Android update</a> with fixes for lots of bugs and crashes. Download it now for a more stable experience!
br
p Thank you for your patience as we worked out some of the hiccups that came with our major code overhaul this weekend. If you like the direction that we're taking the app, we'd love it if you could take the time to leave us a review :) It really helps us out!
p.small.muted by viirus
tr
td
.promo_mystery_201605.pull-right
h3 May Subscriber Items
p The May Subscriber Items have been revealed: the Marching Bard Item Set! You still have six days to <a href='/#/options/settings/subscription'>subscribe</a> and receive the item set.
br
p Subscribers also receive the ability to buy Gems for Gold -- the longer you subscribe, the more Gems you can buy per month! There are other perks as well, such as longer access to uncompressed data. Best of all, your support directly keeps Habitica running. Thank you very much -- it means a lot to us!
.promo_floral_potions.pull-left.slight-right-margin
h3 Last Chance for Marching Bard Item Set
p Reminder: this is the final day to <a href='/#/options/settings/subscription'>subscribe</a> and receive the Marching Bard Item Set! If you want the Marching Bard Hat or the Marching Bard Uniform, now's the time. Thanks so much for your support -- we hope you enjoy your Gems.
p.small.muted by Lemoness
tr
td
h3 Last Chance for Floral Hatching Potions
p Reminder: this is the final day to <a href='/#/options/inventory/drops'>buy Floral Hatching Potions</a>! If they come back, it won't be until next year at the earliest, so don't delay!
p.small.muted by Mako413
if menuItem !== 'oldNews'
hr
a(href='/static/old-news', target='_blank') Read older news
mixin oldNews
h2 5/25/2016 - iOS UPDATE, ANDROID UPDATE, AND MAY SUBSCRIBER ITEMS
tr
td
h3 iOS App Update Available
p We've released <a href='https://itunes.apple.com/us/app/habitica-stay-motivated-gamified/id994882113' target='_blank'>a new iOS app update</a> that contains tons of bug fixes, including for the pet feeding crash and the annoying fake death popups! Be sure to download it now for a more stable Habitica.
br
p Thank you so much to everyone who reported bugs that cropped up after our massive code overhaul! It was very helpful. And if you have a spare moment, we'd love it if you could review this app. It really helps us out! iOS hides all old reviews, but if you've already written one, it's very easy to repost with a single button click.
p.small.muted by viirus
tr
td
h3 Android Update: Bug Fixes
p We've released <a href='https://play.google.com/store/apps/details?id=com.habitrpg.android.habitica&hl=en' target='_blank'>a new Android update</a> with fixes for lots of bugs and crashes. Download it now for a more stable experience!
br
p Thank you for your patience as we worked out some of the hiccups that came with our major code overhaul this weekend. If you like the direction that we're taking the app, we'd love it if you could take the time to leave us a review :) It really helps us out!
p.small.muted by viirus
tr
td
.promo_mystery_201605.pull-right
h3 May Subscriber Items
p The May Subscriber Items have been revealed: the Marching Bard Item Set! You still have six days to <a href='/#/options/settings/subscription'>subscribe</a> and receive the item set.
br
p Subscribers also receive the ability to buy Gems for Gold -- the longer you subscribe, the more Gems you can buy per month! There are other perks as well, such as longer access to uncompressed data. Best of all, your support directly keeps Habitica running. Thank you very much -- it means a lot to us!
p.small.muted by Lemoness
h2 5/21/2016 - WELCOME BACK, HABITICA!
tr
td