Merge remote-tracking branch 'origin/develop' into negue/flagpm

# Conflicts:
#	website/client/components/chat/chatCard.vue
#	website/client/components/chat/chatMessages.vue
#	website/common/locales/en/messages.json
This commit is contained in:
negue
2018-11-18 22:04:33 +01:00
900 changed files with 49269 additions and 42663 deletions

View File

@@ -1,46 +1,32 @@
import validator from 'validator';
import moment from 'moment';
import passport from 'passport';
import nconf from 'nconf';
import {
authWithHeaders,
} from '../../middlewares/auth';
import { model as User } from '../../models/user';
import common from '../../../common';
import {
NotAuthorized,
BadRequest,
NotFound,
} from '../../libs/errors';
import * as passwordUtils from '../../libs/password';
import { model as User } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../../libs/email';
import { send as sendEmail } from '../../libs/email';
import pusher from '../../libs/pusher';
import common from '../../../common';
import { validatePasswordResetCodeAndFindUser, convertToBcrypt} from '../../libs/password';
import { encrypt } from '../../libs/encryption';
import * as authLib from '../../libs/auth';
import {
loginRes,
hasBackupAuth,
loginSocial,
registerLocal,
} from '../../libs/auth';
import {verifyUsername} from '../../libs/user/validation';
const BASE_URL = nconf.get('BASE_URL');
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL');
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS:COMMUNITY_MANAGER_EMAIL');
let api = {};
function hasBackupAuth (user, networkToRemove) {
if (user.auth.local.username) {
return true;
}
let hasAlternateNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS.find((network) => {
return network.key !== networkToRemove && user.auth[network.key].id;
});
return hasAlternateNetwork;
}
/* NOTE this route has also an API v4 version */
/**
* @api {post} /api/v3/user/auth/local/register Register
* @apiDescription Register a new user with email, login name, and password or attach local auth to a social user
@@ -61,15 +47,10 @@ api.registerLocal = {
})],
url: '/user/auth/local/register',
async handler (req, res) {
await authLib.registerLocal(req, res, { isV3: true });
await registerLocal(req, res, { isV3: true });
},
};
function _loginRes (user, req, res) {
if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {communityManagerEmail: COMMUNITY_MANAGER_EMAIL, userId: user._id}));
return res.respond(200, {id: user._id, apiToken: user.apiToken, newUser: user.newUser || false});
}
/**
* @api {post} /api/v3/user/auth/local/login Login
* @apiDescription Login a user with email / username and password
@@ -141,170 +122,19 @@ api.loginLocal = {
headers: req.headers,
});
return _loginRes(user, ...arguments);
return loginRes(user, ...arguments);
},
};
function _passportProfile (network, accessToken) {
return new Promise((resolve, reject) => {
passport._strategies[network].userProfile(accessToken, (err, profile) => {
if (err) {
reject(err);
} else {
resolve(profile);
}
});
});
}
// Called as a callback by Facebook (or other social providers). Internal route
api.loginSocial = {
method: 'POST',
middlewares: [authWithHeaders({
optional: true,
})],
url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2
url: '/user/auth/social',
async handler (req, res) {
let existingUser = res.locals.user;
let accessToken = req.body.authResponse.access_token;
let network = req.body.network;
let isSupportedNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(supportedNetwork => {
return supportedNetwork.key === network;
});
if (!isSupportedNetwork) throw new BadRequest(res.t('unsupportedNetwork'));
let profile = await _passportProfile(network, accessToken);
let user = await User.findOne({
[`auth.${network}.id`]: profile.id,
}, {_id: 1, apiToken: 1, auth: 1}).exec();
// User already signed up
if (user) {
_loginRes(user, ...arguments);
} else { // Create new user
user = {
auth: {
[network]: {
id: profile.id,
emails: profile.emails,
},
},
profile: {
name: profile.displayName || profile.name || profile.username,
},
preferences: {
language: req.language,
},
};
if (existingUser) {
existingUser.auth[network] = user.auth[network];
user = existingUser;
} else {
user = new User(user);
user.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used
}
let savedUser = await user.save();
if (!existingUser) {
user.newUser = true;
}
_loginRes(user, ...arguments);
// Clean previous email preferences
if (savedUser.auth[network].emails && savedUser.auth[network].emails[0] && savedUser.auth[network].emails[0].value) {
EmailUnsubscription
.remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
.exec()
.then(() => {
if (!existingUser) sendTxnEmail(savedUser, 'welcome');
}); // eslint-disable-line max-nested-callbacks
}
if (!existingUser) {
res.analytics.track('register', {
category: 'acquisition',
type: network,
gaLabel: network,
uuid: savedUser._id,
headers: req.headers,
user: savedUser,
});
}
return null;
}
},
};
/*
* @apiIgnore Private route
* @api {post} /api/v3/user/auth/pusher Pusher.com authentication
* @apiDescription Authentication for Pusher.com private and presence channels
* @apiName UserAuthPusher
* @apiGroup User
*
* @apiParam (Body) {String} socket_id A unique identifier for the specific client connection to Pusher
* @apiParam (Body) {String} channel_name The name of the channel being subscribed to
*
* @apiSuccess {String} auth The authentication token
*/
api.pusherAuth = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/auth/pusher',
async handler (req, res) {
let user = res.locals.user;
req.checkBody('socket_id').notEmpty();
req.checkBody('channel_name').notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let socketId = req.body.socket_id;
let channelName = req.body.channel_name;
// Channel names are in the form of {presence|private}-{group|...}-{resourceId}
let [channelType, resourceType, ...resourceId] = channelName.split('-');
if (['presence'].indexOf(channelType) === -1) { // presence is used only for parties, private for guilds
throw new BadRequest('Invalid Pusher channel type.');
}
if (resourceType !== 'group') { // only groups are supported
throw new BadRequest('Invalid Pusher resource type.');
}
resourceId = resourceId.join('-'); // the split at the beginning had split resourceId too
if (!validator.isUUID(String(resourceId))) {
throw new BadRequest('Invalid Pusher resource id, must be a UUID.');
}
// Only the user's party is supported for now
if (user.party._id !== resourceId) {
throw new NotFound('Resource id must be the user\'s party.');
}
let authResult;
// Max 100 members for presence channel - parties only
if (channelType === 'presence') {
let presenceData = {
user_id: user._id, // eslint-disable-line camelcase
// Max 1KB
user_info: {}, // eslint-disable-line camelcase
};
authResult = pusher.authenticate(socketId, channelName, presenceData);
} else {
authResult = pusher.authenticate(socketId, channelName);
}
// Not using res.respond because Pusher requires a different response format
res.status(200).json(authResult);
return await loginSocial(req, res);
},
};
@@ -314,7 +144,6 @@ api.pusherAuth = {
* @apiName UpdateUsername
* @apiGroup User
*
* @apiParam (Body) {String} password The current user password
* @apiParam (Body) {String} username The new username
* @apiSuccess {String} data.username The new username
@@ -324,37 +153,55 @@ api.updateUsername = {
middlewares: [authWithHeaders()],
url: '/user/auth/update-username',
async handler (req, res) {
let user = res.locals.user;
const user = res.locals.user;
req.checkBody({
password: {
notEmpty: {errorMessage: res.t('missingPassword')},
},
username: {
notEmpty: {errorMessage: res.t('missingUsername')},
},
});
let validationErrors = req.validationErrors();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration'));
const newUsername = req.body.username;
let password = req.body.password;
let isValidPassword = await passwordUtils.compare(user, password);
if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword'));
const issues = verifyUsername(newUsername, res);
if (issues.length > 0) throw new BadRequest(issues.join(' '));
let count = await User.count({ 'auth.local.lowerCaseUsername': req.body.username.toLowerCase() });
if (count > 0) throw new BadRequest(res.t('usernameTaken'));
const password = req.body.password;
if (password !== undefined) {
let isValidPassword = await passwordUtils.compare(user, password);
if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword'));
}
const existingUser = await User.findOne({ 'auth.local.lowerCaseUsername': newUsername.toLowerCase() }, {auth: 1}).exec();
if (existingUser !== undefined && existingUser !== null && existingUser._id !== user._id) {
throw new BadRequest(res.t('usernameTaken'));
}
// if password is using old sha1 encryption, change it
if (user.auth.local.passwordHashMethod === 'sha1') {
if (user.auth.local.passwordHashMethod === 'sha1' && password !== undefined) {
await passwordUtils.convertToBcrypt(user, password); // user is saved a few lines below
}
// save username
user.auth.local.lowerCaseUsername = req.body.username.toLowerCase();
user.auth.local.username = req.body.username;
user.auth.local.lowerCaseUsername = newUsername.toLowerCase();
user.auth.local.username = newUsername;
if (!user.flags.verifiedUsername) {
user.flags.verifiedUsername = true;
if (user.items.pets['Bear-Veteran']) {
user.items.pets['Fox-Veteran'] = 5;
} else if (user.items.pets['Lion-Veteran']) {
user.items.pets['Bear-Veteran'] = 5;
} else if (user.items.pets['Tiger-Veteran']) {
user.items.pets['Lion-Veteran'] = 5;
} else if (user.items.pets['Wolf-Veteran']) {
user.items.pets['Tiger-Veteran'] = 5;
} else {
user.items.pets['Wolf-Veteran'] = 5;
}
}
await user.save();
res.respond(200, { username: req.body.username });

View File

@@ -10,7 +10,6 @@ import {
import { removeFromArray } from '../../libs/collectionManipulators';
import { getUserInfo, getGroupUrl, sendTxn } from '../../libs/email';
import slack from '../../libs/slack';
import pusher from '../../libs/pusher';
import { getAuthorEmailFromMessage } from '../../libs/chat';
import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory';
import nconf from 'nconf';
@@ -191,20 +190,11 @@ api.postChat = {
await Promise.all(toSave);
// @TODO: rethink if we want real-time
if (group.privacy === 'private' && group.type === 'party') {
// req.body.pusherSocketId is sent from official clients to identify the sender user's real time socket
// see https://pusher.com/docs/server_api_guide/server_excluding_recipients
pusher.trigger(`presence-group-${group._id}`, 'new-chat', newChatMessage, req.body.pusherSocketId);
}
if (chatUpdated) {
res.respond(200, {chat: chatRes.chat});
} else {
res.respond(200, {message: newChatMessage});
}
group.sendGroupChatReceivedWebhooks(newChatMessage);
},
};

View File

@@ -9,7 +9,6 @@ import {
model as User,
nameFields,
} from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import {
NotFound,
BadRequest,
@@ -17,9 +16,11 @@ import {
} from '../../libs/errors';
import { removeFromArray } from '../../libs/collectionManipulators';
import { sendTxn as sendTxnEmail } from '../../libs/email';
import { encrypt } from '../../libs/encryption';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import pusher from '../../libs/pusher';
import {
inviteByUUID,
inviteByEmail,
inviteByUserName,
} from '../../libs/invites';
import common from '../../../common';
import payments from '../../libs/payments/payments';
import stripePayments from '../../libs/payments/stripe';
@@ -127,10 +128,6 @@ api.createGroup = {
user.achievements.joinedGuild = true;
user.addNotification('GUILD_JOINED_ACHIEVEMENT');
}
if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) {
user._ABtests.counter = -1;
user.markModified('_ABtests');
}
} else {
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty'));
@@ -570,10 +567,6 @@ api.joinGroup = {
user.achievements.joinedGuild = true;
user.addNotification('GUILD_JOINED_ACHIEVEMENT');
}
if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) {
user._ABtests.counter = -1;
user.markModified('_ABtests');
}
}
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
@@ -890,12 +883,6 @@ api.removeGroupMember = {
removeFromArray(member.guilds, group._id);
}
if (isInGroup === 'party') {
// Tell the realtime clients that a user is being removed
// If the user that is being removed is still connected, they'll get disconnected automatically
pusher.trigger(`presence-group-${group._id}`, 'user-removed', {
userId: user._id,
});
member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
}
@@ -934,148 +921,6 @@ api.removeGroupMember = {
},
};
async function _inviteByUUID (uuid, group, inviter, req, res) {
let userToInvite = await User.findById(uuid).exec();
const publicGuild = group.type === 'guild' && group.privacy === 'public';
if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
} else if (inviter._id === userToInvite._id) {
throw new BadRequest(res.t('cannotInviteSelfToGroup'));
}
const objections = inviter.getObjectionsToInteraction('group-invitation', userToInvite);
if (objections.length > 0) {
throw new NotAuthorized(res.t(objections[0], { userId: uuid, username: userToInvite.profile.name}));
}
if (group.type === 'guild') {
if (_.includes(userToInvite.guilds, group._id)) {
throw new NotAuthorized(res.t('userAlreadyInGroup', { userId: uuid, username: userToInvite.profile.name}));
}
if (_.find(userToInvite.invitations.guilds, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name}));
}
let guildInvite = {
id: group._id,
name: group.name,
inviter: inviter._id,
publicGuild,
};
if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true;
userToInvite.invitations.guilds.push(guildInvite);
} else if (group.type === 'party') {
// Do not add to invitations.parties array if the user is already invited to that party
if (_.find(userToInvite.invitations.parties, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name}));
}
if (userToInvite.party._id) {
let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'});
// Allow user to be invited to a new party when they're partying solo
if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name}));
}
let partyInvite = {id: group._id, name: group.name, inviter: inviter._id};
if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true;
userToInvite.invitations.parties.push(partyInvite);
userToInvite.invitations.party = partyInvite;
}
let groupLabel = group.type === 'guild' ? 'Guild' : 'Party';
let groupTemplate = group.type === 'guild' ? 'guild' : 'party';
if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) {
let emailVars = [
{name: 'INVITER', content: inviter.profile.name},
];
if (group.type === 'guild') {
emailVars.push(
{name: 'GUILD_NAME', content: group.name},
{name: 'GUILD_URL', content: '/groups/discovery'}
);
} else {
emailVars.push(
{name: 'PARTY_NAME', content: group.name},
{name: 'PARTY_URL', content: '/party'}
);
}
sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars);
}
if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] !== false) {
let identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty';
sendPushNotification(
userToInvite,
{
title: group.name,
message: res.t(identifier),
identifier,
payload: {groupID: group._id, publicGuild},
}
);
}
let userInvited = await userToInvite.save();
if (group.type === 'guild') {
return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1];
} else if (group.type === 'party') {
return userInvited.invitations.parties[userToInvite.invitations.parties.length - 1];
}
}
async function _inviteByEmail (invite, group, inviter, req, res) {
let userReturnInfo;
if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
let userToContact = await User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
]})
.select({_id: true, 'preferences.emailNotifications': true})
.exec();
if (userToContact) {
userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res);
} else {
userReturnInfo = invite.email;
let cancelledPlan = false;
if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true;
const groupQueryString = JSON.stringify({
id: group._id,
inviter: inviter._id,
publicGuild: group.type === 'guild' && group.privacy === 'public',
sentAt: Date.now(), // so we can let it expire
cancelledPlan,
});
let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`;
let variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || inviter.profile.name},
];
if (group.type === 'guild') {
variables.push({name: 'GUILD_NAME', content: group.name});
}
// Check for the email address not to be unsubscribed
let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec();
let groupLabel = group.type === 'guild' ? '-guild' : '';
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
}
return userReturnInfo;
}
/**
* @api {post} /api/v3/groups/:groupId/invite Invite users to a group
* @apiName InviteToGroup
@@ -1162,7 +1007,7 @@ api.inviteToGroup = {
url: '/groups/:groupId/invite',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
const user = res.locals.user;
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted'));
@@ -1170,35 +1015,48 @@ api.inviteToGroup = {
if (user.invitesSent >= MAX_EMAIL_INVITES_BY_USER) throw new NotAuthorized(res.t('inviteLimitReached', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL }));
let validationErrors = req.validationErrors();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'});
const group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.purchased && group.purchased.plan.customerId && user._id !== group.leader) throw new NotAuthorized(res.t('onlyGroupLeaderCanInviteToGroupPlan'));
let uuids = req.body.uuids;
let emails = req.body.emails;
const {
uuids,
emails,
usernames,
} = req.body;
await Group.validateInvitations(uuids, emails, res, group);
await Group.validateInvitations({
uuids,
emails,
usernames,
}, res, group);
let results = [];
const results = [];
if (uuids) {
let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res));
let uuidResults = await Promise.all(uuidInvites);
const uuidInvites = uuids.map((uuid) => inviteByUUID(uuid, group, user, req, res));
const uuidResults = await Promise.all(uuidInvites);
results.push(...uuidResults);
}
if (emails) {
let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res));
const emailInvites = emails.map((invite) => inviteByEmail(invite, group, user, req, res));
user.invitesSent += emails.length;
await user.save();
let emailResults = await Promise.all(emailInvites);
const emailResults = await Promise.all(emailInvites);
results.push(...emailResults);
}
if (usernames) {
const usernameInvites = usernames.map((username) => inviteByUserName(username, group, user, req, res));
const usernameResults = await Promise.all(usernameInvites);
results.push(...usernameResults);
}
let analyticsObject = {
uuid: user._id,
hitType: 'event',

View File

@@ -32,14 +32,14 @@ let api = {};
*
* @apiSuccess {Object} data The member object
*
* @apiSuccess (Object) data.inbox Basic information about person's inbox
* @apiSuccess (Object) data.stats Includes current stats and buffs
* @apiSuccess (Object) data.profile Includes name
* @apiSuccess (Object) data.preferences Includes info about appearance and public prefs
* @apiSuccess (Object) data.party Includes basic info about current party and quests
* @apiSuccess (Object) data.items Basic inventory information includes quests, food, potions, eggs, gear, special items
* @apiSuccess (Object) data.achievements Lists current achievements
* @apiSuccess (Object) data.auth Includes latest timestamps
* @apiSuccess {Object} data.inbox Basic information about person's inbox
* @apiSuccess {Object} data.stats Includes current stats and buffs
* @apiSuccess {Object} data.profile Includes name
* @apiSuccess {Object} data.preferences Includes info about appearance and public prefs
* @apiSuccess {Object} data.party Includes basic info about current party and quests
* @apiSuccess {Object} data.items Basic inventory information includes quests, food, potions, eggs, gear, special items
* @apiSuccess {Object} data.achievements Lists current achievements
* @apiSuccess {Object} data.auth Includes latest timestamps
*
* @apiSuccessExample {json} Success-Response:
* {
@@ -109,6 +109,36 @@ api.getMember = {
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
if (!member.flags.verifiedUsername) member.auth.local.username = null;
// manually call toJSON with minimize: true so empty paths aren't returned
let memberToJSON = member.toJSON({minimize: true});
User.addComputedStatsToJSONObj(memberToJSON.stats, member);
res.respond(200, memberToJSON);
},
};
api.getMemberByUsername = {
method: 'GET',
url: '/members/username/:username',
middlewares: [],
async handler (req, res) {
req.checkParams('username', res.t('invalidReqParams')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let username = req.params.username.toLowerCase();
if (username[0] === '@') username = username.slice(1, username.length);
let member = await User
.findOne({'auth.local.lowerCaseUsername': username, 'flags.verifiedUsername': true})
.select(memberFields)
.exec();
if (!member) throw new NotFound(res.t('userNotFound'));
// manually call toJSON with minimize: true so empty paths aren't returned
let memberToJSON = member.toJSON({minimize: true});
User.addComputedStatsToJSONObj(memberToJSON.stats, member);
@@ -605,6 +635,7 @@ api.sendPrivateMessage = {
const message = req.body.message;
const receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));

View File

@@ -3,7 +3,7 @@ import { authWithHeaders } from '../../middlewares/auth';
let api = {};
// @TODO export this const, cannot export it from here because only routes are exported from controllers
const LAST_ANNOUNCEMENT_TITLE = 'FALL FESTIVAL BEGINS! LIMITED EDITION FALL EQUIPMENT, SEASONAL SHOP OPENS, AND NPC OUTFITS!';
const LAST_ANNOUNCEMENT_TITLE = 'ODDBALLS PET QUEST BUNDLE AND SPOTLIGHT ON SLEEP';
const worldDmg = { // @TODO
bailey: false,
};
@@ -30,21 +30,20 @@ api.getNews = {
<div class="mr-3 ${baileyClass}"></div>
<div class="media-body">
<h1 class="align-self-center">${res.t('newStuff')}</h1>
<h2>9/20/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<h2>11/15/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
</div>
</div>
<hr/>
<div class="promo_fall_festival_2018 center-block"></div>
<h3>Limited Edition Class Outfits!</h3>
<p>From now until October 31st, limited edition outfits are available in the Rewards column! Depending on your class, you can be a Minotaur Warrior, an Alter Ego Rogue, a Carnivorous Plant Healer, or a Candymancer Mage. You'd better get productive to earn enough gold before your time runs out...</p>
<div class="small mb-3">by AnnDeLune, Vikte, QuartzFox, Beffymaroo, and SabreCat</div>
<div class="promo_fall_festival_2017 center-block"></div>
<h3>Seasonal Shop Opens</h3>
<p>The <a href='/shops/seasonal' target='_blank'>Seasonal Shop</a> has opened! It's stocking autumnal Seasonal Edition goodies at the moment, including past fall outfits. Everything there will be available to purchase during the Fall Festival event each year, but it's only open until October 31st, so be sure to stock up now, or you'll have to wait a year to buy these items again!</p>
<div class="small mb-3">by AnnDeLune, ʂʈєƒąʃųƥągųʂ, Katy133, Lilith of Alfheim, Definitely not a villain, ShoGirlGeek. cataclysms, maxpendragon, Lemoness, Beffymaroo, and SabreCat</div>
<h3>NPC Outfits</h3>
<p>Everyone has hastened down to the Flourishing Fields to celebrate this spooky harvest festival. Be sure to check out all the new outfits that people are sporting!</p>
<div class="promo_seasonal_shop center-block"></div>
<div class="promo_oddballs_bundle center-block"></div>
<h3>New Discounted Pet Quest Bundle: Oddballs!</h3>
<p>If you are looking to add some goofy offbeat pets to your Habitica stable, you're in luck! From now until November 30, you can purchase the Oddball Pet Quest Bundle and receive the Rock, Marshmallow Slime, and Yarn quests, all for only 7 Gems! That's a discount of 5 Gems from the price of purchasing them separately. Check it out in the <a href='/shops/quests'>Quest Shop</a> today!</p>
<div class="small">Art by PainterProphet, Pfeffernusse, Zorelya, intune, starsystemic, Leephon, Arcosine, stefalupagus, Hachiseiko, TheMushroomKing, khdarkwolf, Vampitch, JinjooHat, UncommonCriminal, Oranges, Darkly, overomega, celticdragon, and Shaner</div>
<div class="small mb-3">Writing by Bartelmy, Faelwyn the Rising Phoenix, Theothermeme, Bethany Woll, itokro, and Lemoness</div>
<div class="scene_sleep center-block"></div>
<h3>Use Case Spotlight and Guild Spotlight on Sleep and Rest</h3>
<p>We've got new posts on the blog all about ways to use Habitica to help you with self-care related to sleep and rest! First, there's a <a href='https://habitica.wordpress.com/2018/11/15/taking-a-break-guild-spotlight/' target='_blank'>Guild Spotlight</a> that highlights the Guilds that can help you as you explore ways to use Habitica to help with sleep hygiene and taking breaks. We've also posted a <a href='https://habitica.wordpress.com/2018/11/15/use-case-spotlight-sleep-and-rest/' target='_blank'>Use Case Spotlight</a> featuring a number of great suggestions for using Habitica's task system to manage this as well! These suggestions were submitted by Habiticans in the <a href='/groups/guild/1d3a10bf-60aa-4806-a38b-82d1084a59e6' target='_blank'>Use Case Spotlights Guild</a>.</p>
<p>Plus, we're collecting user submissions for the next Use Case Spotlight! How do you use Habitica for professionalization and "adulting" skills? Well be featuring player-submitted examples in Use Case Spotlights on the Habitica Blog next month, so post your suggestions in the Use Case Spotlight Guild now. We look forward to learning more about how you use Habitica to improve your life and get things done!</p>
<div class="small mb-3">by shanaqui</div>
</div>
`,
});

View File

@@ -1,6 +1,5 @@
import { authWithHeaders } from '../../middlewares/auth';
import {
NotAuthorized,
NotFound,
} from '../../libs/errors';
import { model as PushDevice } from '../../models/pushDevice';
@@ -39,8 +38,10 @@ api.addPushDevice = {
type: req.body.type,
};
// When adding a duplicate push device, fail silently instead of throwing an error
if (pushDevices.find(device => device.regId === item.regId)) {
throw new NotAuthorized(res.t('pushDeviceAlreadyAdded'));
res.respond(200, user.pushDevices, res.t('pushDeviceAdded'));
return;
}
// Concurrency safe update

View File

@@ -630,18 +630,6 @@ api.scoreTask = {
setNextDue(task, user);
if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) {
user._ABtests.counter++;
if (user._ABtests.counter > 1) {
if (user._ABtests.guildReminder.indexOf('timing1') !== -1 || user._ABtests.counter > 4) {
user._ABtests.counter = -1;
let textVariant = user._ABtests.guildReminder.indexOf('text2');
user.addNotification('GUILD_PROMPT', {textVariant});
}
}
user.markModified('_ABtests');
}
let promises = [
user.save(),
task.save(),

View File

@@ -62,7 +62,7 @@ let api = {};
* Tags
* TasksOrder (list of all ids for dailys, habits, rewards and todos)
*
* @apiParam (Query) {UUID} userFields A list of comma separated user fields to be returned instead of the entire document. Notifications are always returned.
* @apiParam (Query) {String} [userFields] A list of comma separated user fields to be returned instead of the entire document. Notifications are always returned.
*
* @apiExample {curl} Example use:
* curl -i https://habitica.com/api/v3/user?userFields=achievements,items.mounts

View File

@@ -31,7 +31,7 @@ let api = {};
* @apiParam (Body) {String} url The webhook's URL
* @apiParam (Body) {String} [label] A label to remind you what this webhook does
* @apiParam (Body) {Boolean} [enabled=true] If the webhook should be enabled
* @apiParam (Body) {Sring="taskActivity","groupChatReceived","userActivity"} [type="taskActivity"] The webhook's type.
* @apiParam (Body) {String="taskActivity","groupChatReceived","userActivity"} [type="taskActivity"] The webhook's type.
* @apiParam (Body) {Object} [options] The webhook's options. Wil differ depending on type. Required for `groupChatReceived` type. If a webhook supports options, the default values are displayed in the examples below
* @apiParamExample {json} Task Activity Example
* {
@@ -105,7 +105,7 @@ api.addWebhook = {
* @apiParam (Body) {String} [url] The webhook's URL
* @apiParam (Body) {String} [label] A label to remind you what this webhook does
* @apiParam (Body) {Boolean} [enabled] If the webhook should be enabled
* @apiParam (Body) {Sring="taskActivity","groupChatReceived"} [type] The webhook's type.
* @apiParam (Body) {String="taskActivity","groupChatReceived"} [type] The webhook's type.
* @apiParam (Body) {Object} [options] The webhook's options. Wil differ depending on type. The options are enumerated in the [add webhook examples](#api-Webhook-UserAddWebhook).
* @apiParamExample {json} Update Enabled and Type Properties
* {