mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 22:57:21 +01:00
Merge branch 'develop' into Yutsuten/party-chat-translations
This commit is contained in:
@@ -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');
|
||||
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_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
|
||||
@@ -117,6 +98,9 @@ api.loginLocal = {
|
||||
// load the entire user because we may have to save it to convert the password to bcrypt
|
||||
let user = await User.findOne(login).exec();
|
||||
|
||||
// if user is using social login, then user will not have a hashed_password stored
|
||||
if (!user || !user.auth.local.hashed_password) throw new NotAuthorized(res.t('invalidLoginCredentialsLong'));
|
||||
|
||||
let isValidPassword;
|
||||
|
||||
if (!user) {
|
||||
@@ -141,170 +125,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 +147,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 +156,57 @@ 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;
|
||||
}
|
||||
|
||||
user.markModified('items.pets');
|
||||
}
|
||||
await user.save();
|
||||
|
||||
res.respond(200, { username: req.body.username });
|
||||
|
||||
@@ -49,7 +49,7 @@ let api = {};
|
||||
* @apiSuccess {String} challenge.name Full name of challenge.
|
||||
* @apiSuccess {String} challenge.shortName A shortened name for the challenge, to be used as a tag.
|
||||
* @apiSuccess {Object} challenge.leader User details of challenge leader.
|
||||
* @apiSuccess {UUID} challenge.leader._id User id of challenge leader.
|
||||
* @apiSuccess {UUID} challenge.leader._id User ID of challenge leader.
|
||||
* @apiSuccess {Object} challenge.leader.profile Profile information of leader.
|
||||
* @apiSuccess {Object} challenge.leader.profile.name Display Name of leader.
|
||||
* @apiSuccess {String} challenge.updatedAt Timestamp of last update.
|
||||
@@ -364,12 +364,14 @@ api.getUserChallenges = {
|
||||
$and: [{$or: orOptions}],
|
||||
};
|
||||
|
||||
if (owned && owned === 'not_owned') {
|
||||
query.$and.push({leader: {$ne: user._id}});
|
||||
}
|
||||
if (owned) {
|
||||
if (owned === 'not_owned') {
|
||||
query.$and = [{leader: {$ne: user._id}}];
|
||||
}
|
||||
|
||||
if (owned && owned === 'owned') {
|
||||
query.$and.push({leader: user._id});
|
||||
if (owned === 'owned') {
|
||||
query.$and = [{leader: user._id}];
|
||||
}
|
||||
}
|
||||
|
||||
if (req.query.search) {
|
||||
@@ -400,7 +402,6 @@ api.getUserChallenges = {
|
||||
// .populate('leader', nameFields)
|
||||
const challenges = await mongoQuery.exec();
|
||||
|
||||
|
||||
let resChals = challenges.map(challenge => challenge.toJSON());
|
||||
|
||||
resChals = _.orderBy(resChals, [challenge => {
|
||||
@@ -410,7 +411,7 @@ api.getUserChallenges = {
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Promise.all(resChals.map((chal, index) => {
|
||||
return Promise.all([
|
||||
User.findById(chal.leader).select(nameFields).exec(),
|
||||
User.findById(chal.leader).select(`${nameFields} backer contributor`).exec(),
|
||||
Group.findById(chal.group).select(basicGroupFields).exec(),
|
||||
]).then(populatedData => {
|
||||
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
|
||||
@@ -577,7 +578,7 @@ api.exportChallengeCsv = {
|
||||
.lean().exec(),
|
||||
]);
|
||||
|
||||
let resArray = members.map(member => [member._id, member.profile.name]);
|
||||
let resArray = members.map(member => [member._id, member.profile.name, member.auth.local.username]);
|
||||
|
||||
let lastUserId;
|
||||
let index = -1;
|
||||
@@ -606,7 +607,7 @@ api.exportChallengeCsv = {
|
||||
let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => {
|
||||
return result.concat(array);
|
||||
}, []).sort();
|
||||
resArray.unshift(['UUID', 'name']);
|
||||
resArray.unshift(['UUID', 'Display Name', 'Username']);
|
||||
|
||||
_.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes', 'Streak'));
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -31,12 +30,17 @@ const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email)
|
||||
|
||||
/**
|
||||
* @apiDefine GroupIdRequired
|
||||
* @apiError (404) {badRequest} groupIdRequired A group ID is required
|
||||
* @apiError (400) {badRequest} groupIdRequired A group ID is required
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine ChatIdRequired
|
||||
* @apiError (404) {badRequest} chatIdRequired A chat ID is required
|
||||
* @apiError (400) {badRequest} chatIdRequired A chat ID is required
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine MessageIdRequired
|
||||
* @apiError (400) {badRequest} messageIdRequired A message ID is required
|
||||
*/
|
||||
|
||||
let api = {};
|
||||
@@ -84,6 +88,8 @@ function getBannedWordsFromText (message) {
|
||||
return getMatchesByWordArray(message, bannedWords);
|
||||
}
|
||||
|
||||
|
||||
const mentionRegex = new RegExp('\\B@[-\\w]+', 'g');
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/chat Post chat message to a group
|
||||
* @apiName PostChat
|
||||
@@ -176,7 +182,11 @@ api.postChat = {
|
||||
throw new NotAuthorized(res.t('messageGroupChatSpam'));
|
||||
}
|
||||
|
||||
const newChatMessage = group.sendChat({message: req.body.message, user});
|
||||
let client = req.headers['x-client'] || '3rd Party';
|
||||
if (client) {
|
||||
client = client.replace('habitica-', '');
|
||||
}
|
||||
const newChatMessage = group.sendChat({message: req.body.message, user, metaData: null, client});
|
||||
let toSave = [newChatMessage.save()];
|
||||
|
||||
if (group.type === 'party') {
|
||||
@@ -186,20 +196,32 @@ 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);
|
||||
let analyticsObject = {
|
||||
uuid: user._id,
|
||||
hitType: 'event',
|
||||
category: 'behavior',
|
||||
groupType: group.type,
|
||||
privacy: group.privacy,
|
||||
headers: req.headers,
|
||||
};
|
||||
|
||||
const mentions = req.body.message.match(mentionRegex);
|
||||
if (mentions) {
|
||||
analyticsObject.mentionsCount = mentions.length;
|
||||
} else {
|
||||
analyticsObject.mentionsCount = 0;
|
||||
}
|
||||
if (group.privacy === 'public') {
|
||||
analyticsObject.groupName = group.name;
|
||||
}
|
||||
|
||||
res.analytics.track('group chat', analyticsObject);
|
||||
|
||||
if (chatUpdated) {
|
||||
res.respond(200, {chat: chatRes.chat});
|
||||
} else {
|
||||
res.respond(200, {message: newChatMessage});
|
||||
}
|
||||
|
||||
group.sendGroupChatReceivedWebhooks(newChatMessage);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -229,7 +251,7 @@ api.likeChat = {
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', apiError('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
@@ -259,6 +281,7 @@ api.likeChat = {
|
||||
*
|
||||
* @apiParam (Path) {UUID} groupId The group id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam (Path) {UUID} chatId The chat message id
|
||||
* @apiParam (Body) {String} [comment] explain why the message was flagged
|
||||
*
|
||||
* @apiSuccess {Object} data The flagged chat message
|
||||
* @apiSuccess {UUID} data.id The id of the message
|
||||
@@ -267,7 +290,7 @@ api.likeChat = {
|
||||
* @apiSuccess {Object} data.likes The likes of the message
|
||||
* @apiSuccess {Object} data.flags The flags of the message
|
||||
* @apiSuccess {Number} data.flagCount The number of flags the message has
|
||||
* @apiSuccess {UUID} data.uuid The user id of the author of the message
|
||||
* @apiSuccess {UUID} data.uuid The User ID of the author of the message
|
||||
* @apiSuccess {String} data.user The username of the author of the message
|
||||
*
|
||||
* @apiUse GroupNotFound
|
||||
@@ -316,7 +339,7 @@ api.clearChatFlags = {
|
||||
let chatId = req.params.chatId;
|
||||
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', apiError('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
@@ -452,7 +475,7 @@ api.deleteChat = {
|
||||
let chatId = req.params.chatId;
|
||||
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', apiError('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
@@ -135,6 +135,7 @@ api.modifyInventory = {
|
||||
|
||||
if (gear) {
|
||||
user.items.gear.owned = gear;
|
||||
user.markModified('items.gear.owned');
|
||||
}
|
||||
|
||||
[
|
||||
@@ -148,6 +149,7 @@ api.modifyInventory = {
|
||||
].forEach((type) => {
|
||||
if (req.body[type]) {
|
||||
user.items[type] = req.body[type];
|
||||
user.markModified(`items.${type}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -28,7 +29,7 @@ import shared from '../../../common';
|
||||
import apiError from '../../libs/apiError';
|
||||
|
||||
const MAX_EMAIL_INVITES_BY_USER = 200;
|
||||
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL');
|
||||
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
|
||||
|
||||
/**
|
||||
* @apiDefine GroupBodyInvalid
|
||||
@@ -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'));
|
||||
|
||||
@@ -602,6 +595,7 @@ api.joinGroup = {
|
||||
inviter.items.quests.basilist = 0;
|
||||
}
|
||||
inviter.items.quests.basilist++;
|
||||
inviter.markModified('items.quests');
|
||||
}
|
||||
promises.push(inviter.save());
|
||||
}
|
||||
@@ -890,12 +884,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()?
|
||||
}
|
||||
|
||||
@@ -903,6 +891,7 @@ api.removeGroupMember = {
|
||||
|
||||
if (group.quest && group.quest.active && group.quest.leader === member._id) {
|
||||
member.items.quests[group.quest.key] += 1;
|
||||
member.markModified('items.quests');
|
||||
}
|
||||
} else if (isInvited) {
|
||||
if (isInvited === 'guild') {
|
||||
@@ -934,148 +923,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
|
||||
@@ -1096,11 +943,11 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
|
||||
* {"name": "User2", "email": "user-2@example.com"}
|
||||
* ]
|
||||
* }
|
||||
* @apiParamExample {json} User Ids
|
||||
* @apiParamExample {json} User IDs
|
||||
* {
|
||||
* "uuids": ["user-id-of-existing-user", "user-id-of-another-existing-user"]
|
||||
* }
|
||||
* @apiParamExample {json} User Ids and Emails
|
||||
* @apiParamExample {json} User IDs and Emails
|
||||
* {
|
||||
* "emails": [
|
||||
* {"email": "user-1@example.com"},
|
||||
@@ -1110,7 +957,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
|
||||
* }
|
||||
*
|
||||
* @apiSuccess {Array} data The invites
|
||||
* @apiSuccess {Object} data[0] If the invitation was a user id, you'll receive back an object. You'll receive one Object for each succesful user id invite.
|
||||
* @apiSuccess {Object} data[0] If the invitation was a User ID, you'll receive back an object. You'll receive one Object for each succesful User ID invite.
|
||||
* @apiSuccess {String} data[1] If the invitation was an email, you'll receive back the email. You'll receive one String for each successful email invite.
|
||||
*
|
||||
* @apiSuccessExample {json} Successful Response with Emails
|
||||
@@ -1121,13 +968,13 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @apiSuccessExample {json} Successful Response with User Id
|
||||
* @apiSuccessExample {json} Successful Response with User ID
|
||||
* {
|
||||
* "data": [
|
||||
* { id: 'the-id-of-the-invited-user', name: 'The group name', inviter: 'your-user-id' }
|
||||
* ]
|
||||
* }
|
||||
* @apiSuccessExample {json} Successful Response with User Ids and Emails
|
||||
* @apiSuccessExample {json} Successful Response with User IDs and Emails
|
||||
* {
|
||||
* "data": [
|
||||
* "user-1@example.com",
|
||||
@@ -1142,9 +989,9 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
|
||||
* param `Array`.
|
||||
* @apiError (400) {BadRequest} UuidOrEmailOnly The `emails` and `uuids` params were both missing and/or a
|
||||
* key other than `emails` or `uuids` was provided in the body param.
|
||||
* @apiError (400) {BadRequest} CannotInviteSelf User id or email of invitee matches that of the inviter.
|
||||
* @apiError (400) {BadRequest} CannotInviteSelf User ID or email of invitee matches that of the inviter.
|
||||
* @apiError (400) {BadRequest} MustBeArray The `uuids` or `emails` body param was not an array.
|
||||
* @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined emails and user ids) can
|
||||
* @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined emails and User IDs) can
|
||||
* be sent out at a time.
|
||||
* @apiError (400) {BadRequest} ExceedsMembersLimit A max of 30 members can join a party.
|
||||
*
|
||||
@@ -1162,7 +1009,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 +1017,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',
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
} from '../../libs/errors';
|
||||
import _ from 'lodash';
|
||||
import apiError from '../../libs/apiError';
|
||||
import validator from 'validator';
|
||||
import {
|
||||
validateItemPath,
|
||||
castItemVal,
|
||||
} from '../../libs/items/utils';
|
||||
|
||||
|
||||
let api = {};
|
||||
|
||||
@@ -142,7 +148,7 @@ api.getHeroes = {
|
||||
const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked';
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID or Username
|
||||
* @apiParam (Path) {UUID} heroId user ID
|
||||
* @apiName GetHero
|
||||
* @apiGroup Hall
|
||||
@@ -162,15 +168,23 @@ api.getHero = {
|
||||
url: '/hall/heroes/:heroId',
|
||||
middlewares: [authWithHeaders(), ensureAdmin],
|
||||
async handler (req, res) {
|
||||
let heroId = req.params.heroId;
|
||||
let validationErrors;
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty();
|
||||
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let hero = await User
|
||||
.findById(heroId)
|
||||
const heroId = req.params.heroId;
|
||||
|
||||
let query;
|
||||
if (validator.isUUID(heroId)) {
|
||||
query = {_id: heroId};
|
||||
} else {
|
||||
query = {'auth.local.lowerCaseUsername': heroId.toLowerCase()};
|
||||
}
|
||||
|
||||
const hero = await User
|
||||
.findOne(query)
|
||||
.select(heroAdminFields)
|
||||
.exec();
|
||||
|
||||
@@ -188,7 +202,7 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/hall/heroes/:heroId Update any user ("hero")
|
||||
* @apiParam (Path) {UUID} heroId user ID
|
||||
* @apiParam (Path) {UUID} heroId User ID
|
||||
* @apiName UpdateHero
|
||||
* @apiGroup Hall
|
||||
* @apiPermission Admin
|
||||
@@ -255,11 +269,12 @@ api.updateHero = {
|
||||
if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads;
|
||||
|
||||
// give them the Dragon Hydra pet if they're above level 6
|
||||
if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5;
|
||||
if (updateData.itemPath && updateData.itemVal &&
|
||||
updateData.itemPath.indexOf('items.') === 0 &&
|
||||
User.schema.paths[updateData.itemPath]) {
|
||||
_.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
|
||||
if (hero.contributor.level >= 6) {
|
||||
hero.items.pets['Dragon-Hydra'] = 5;
|
||||
hero.markModified('items.pets');
|
||||
}
|
||||
if (updateData.itemPath && updateData.itemVal && validateItemPath(updateData.itemPath)) {
|
||||
_.set(hero, updateData.itemPath, castItemVal(updateData.itemPath, updateData.itemVal)); // Sanitization at 5c30944 (deemed unnecessary)
|
||||
}
|
||||
|
||||
if (updateData.auth && updateData.auth.blocked === true) {
|
||||
|
||||
@@ -11,6 +11,9 @@ let api = {};
|
||||
* @apiGroup Inbox
|
||||
* @apiDescription Get inbox messages for a user
|
||||
*
|
||||
* @apiParam (Query) {Number} page Load the messages of the selected Page - 10 Messages per Page
|
||||
* @apiParam (Query) {GUID} conversation Loads only the messages of a conversation
|
||||
*
|
||||
* @apiSuccess {Array} data An array of inbox messages
|
||||
*/
|
||||
api.getInboxMessages = {
|
||||
@@ -19,8 +22,12 @@ api.getInboxMessages = {
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
const user = res.locals.user;
|
||||
const page = req.query.page;
|
||||
const conversation = req.query.conversation;
|
||||
|
||||
const userInbox = await inboxLib.getUserInbox(user);
|
||||
const userInbox = await inboxLib.getUserInbox(user, {
|
||||
page, conversation,
|
||||
});
|
||||
|
||||
res.respond(200, userInbox);
|
||||
},
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 = 'SLEEPY AVATARS; LAST CHANCE FOR AUTUMNAL ARMOR SET AND FOREST FRIENDS BUNDLE';
|
||||
const LAST_ANNOUNCEMENT_TITLE = 'MAY BACKGROUNDS AND ARMOIRE ITEMS!';
|
||||
const worldDmg = { // @TODO
|
||||
bailey: false,
|
||||
};
|
||||
@@ -30,24 +30,14 @@ api.getNews = {
|
||||
<div class="mr-3 ${baileyClass}"></div>
|
||||
<div class="media-body">
|
||||
<h1 class="align-self-center">${res.t('newStuff')}</h1>
|
||||
<h2>9/27/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
|
||||
<h2>5/7/2019 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<h3>Is Your Avatar Asleep?</h3>
|
||||
<p>Hey Habiticans! You may have noticed we experienced a server outage on 25-September. During this issue, we <a href='https://habitica.wikia.com/wiki/Rest_in_the_Inn' target='_blank'>put all users in the Inn</a> to prevent any unfair damage. To check out of the Inn and resume damage from Dailies as well as boss damage in Quests, go to Menu>Social>Tavern>Details (on mobile) or Guilds>Tavern (on web) and tap the orange banner that says "Resume Damage."</p>
|
||||
<p>Thank you all for your patience and support during the outage. We are always grateful for our exceptionally wonderful community! <3</p>
|
||||
<div class="promo_mystery_201809 center-block"></div>
|
||||
<h3>Last Chance for Autumnal Armor Set</h3>
|
||||
<p>Reminder: this weekend is your last chance to <a href='/user/settings/subscription' target='_blank'>subscribe</a> and receive the Autumnal Set! Subscribing also lets you buy Gems for Gold. The longer your subscription, the more Gems you get!</p>
|
||||
<p>Thanks so much for your support! You help keep Habitica running.</p>
|
||||
<div class="small mb-3">by Beffymaroo</div>
|
||||
<div class="promo_forest_friends_bundle center-block"></div>
|
||||
<h3>Last Chance for Forest Friends Quest Bundle</h3>
|
||||
<p>This is also the final week to buy the discounted Forest Friends Pet Quest Bundle, featuring the Deer, Hedgehog, and Treeling quests all for seven Gems! Be sure to grab this bundle from the <a href='/shops/quests' target='_blank'>Quest Shop</a> before it scampers into the underbrush!</p>
|
||||
<div class="small">by Beffymaroo and SabreCat</div>
|
||||
<div class="small">Art by Uncommon Criminal, InspectorCaracal, Leephon, aurakami, FuzzyTrees, PainterProphet, and plumilla</div>
|
||||
<div class="small mb-3">Writing by Daniel the Bard, Flutter Bee, and Lemoness</div>
|
||||
<div class="promo_armoire_backgrounds_201905 center-block"></div>
|
||||
<p>We’ve added three new backgrounds to the Background Shop! Now your avatar can learn new moves in the Dojo, stroll in a Park with a Statue, and admire a Rainbow Meadow. Check them out under User Icon > Backgrounds!</p>
|
||||
<p>Plus, there’s new Gold-purchasable equipment in the Enchanted Armoire, including the Nephrite Archer Set. Better work hard on your real-life tasks to earn all the pieces! Enjoy :)</p>
|
||||
<div class="small mb-3">by Balduranne, QuartzFox, AnnDeLune, GeraldThePixel, Reesachan, and Mewrose</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import {
|
||||
NotFound,
|
||||
NotificationNotFound,
|
||||
} from '../../libs/errors';
|
||||
import {
|
||||
model as User,
|
||||
@@ -37,7 +37,7 @@ api.readNotification = {
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
throw new NotFound(res.t('messageNotificationNotFound'));
|
||||
throw new NotificationNotFound(req.language);
|
||||
}
|
||||
|
||||
user.notifications.splice(index, 1);
|
||||
@@ -81,7 +81,7 @@ api.readNotifications = {
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
throw new NotFound(res.t('messageNotificationNotFound'));
|
||||
throw new NotificationNotFound(req.language);
|
||||
}
|
||||
|
||||
user.notifications.splice(index, 1);
|
||||
@@ -129,7 +129,7 @@ api.seeNotification = {
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
throw new NotFound(res.t('messageNotificationNotFound'));
|
||||
throw new NotificationNotFound(req.language);
|
||||
}
|
||||
|
||||
notification.seen = true;
|
||||
@@ -179,7 +179,7 @@ api.seeNotifications = {
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
throw new NotFound(res.t('messageNotificationNotFound'));
|
||||
throw new NotificationNotFound(req.language);
|
||||
}
|
||||
|
||||
notification.seen = true;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -245,7 +245,7 @@ api.rejectQuest = {
|
||||
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
|
||||
if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected'));
|
||||
|
||||
user.party.quest = Group.cleanQuestProgress();
|
||||
user.party.quest = Group.cleanQuestUser(user.party.quest.progress);
|
||||
user.markModified('party.quest');
|
||||
await user.save();
|
||||
|
||||
@@ -363,20 +363,25 @@ api.cancelQuest = {
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: basicGroupFields.concat(' quest')});
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
|
||||
if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest'));
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
|
||||
|
||||
let questName = questScrolls[group.quest.key].text('en');
|
||||
const newChatMessage = group.sendChat(`\`${user.profile.name} cancelled the party quest ${questName}.\``);
|
||||
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
let [savedGroup] = await Promise.all([
|
||||
group.save(),
|
||||
newChatMessage.save(),
|
||||
User.update(
|
||||
{'party._id': groupId},
|
||||
{$set: {'party.quest': Group.cleanQuestProgress()}},
|
||||
Group.cleanQuestParty(),
|
||||
{multi: true}
|
||||
).exec(),
|
||||
]);
|
||||
@@ -405,7 +410,7 @@ api.abortQuest = {
|
||||
url: '/groups/:groupId/quests/abort',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
// Abort a quest AFTER it has begun (see questCancel for BEFORE)
|
||||
// Abort a quest AFTER it has begun
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
@@ -434,9 +439,8 @@ api.abortQuest = {
|
||||
|
||||
let memberUpdates = User.update({
|
||||
'party._id': groupId,
|
||||
}, {
|
||||
$set: {'party.quest': Group.cleanQuestProgress()},
|
||||
}, {multi: true}).exec();
|
||||
}, Group.cleanQuestParty(),
|
||||
{multi: true}).exec();
|
||||
|
||||
let questLeaderUpdate = User.update({
|
||||
_id: group.quest.leader,
|
||||
@@ -491,7 +495,7 @@ api.leaveQuest = {
|
||||
group.quest.members[user._id] = false;
|
||||
group.markModified('quest.members');
|
||||
|
||||
user.party.quest = Group.cleanQuestProgress();
|
||||
user.party.quest = Group.cleanQuestUser(user.party.quest.progress);
|
||||
user.markModified('party.quest');
|
||||
|
||||
let [savedGroup] = await Promise.all([
|
||||
|
||||
@@ -182,7 +182,10 @@ api.reorderTags = {
|
||||
return tag.id === req.body.tagId;
|
||||
});
|
||||
if (tagIndex === -1) throw new NotFound(res.t('tagNotFound'));
|
||||
user.tags.splice(req.body.to, 0, user.tags.splice(tagIndex, 1)[0]);
|
||||
|
||||
const removedItem = user.tags.splice(tagIndex, 1)[0];
|
||||
|
||||
user.tags.splice(req.body.to, 0, removedItem);
|
||||
|
||||
await user.save();
|
||||
res.respond(200, {});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -213,6 +213,14 @@ api.assignTask = {
|
||||
},
|
||||
});
|
||||
promises.push(newMessage.save());
|
||||
} else {
|
||||
const taskText = task.text;
|
||||
const managerName = user.profile.name;
|
||||
|
||||
assignedUser.addNotification('GROUP_TASK_ASSIGNED', {
|
||||
message: res.t('youHaveBeenAssignedTask', {managerName, taskText}),
|
||||
taskId: task._id,
|
||||
});
|
||||
}
|
||||
|
||||
promises.push(group.syncTask(task, assignedUser));
|
||||
@@ -268,6 +276,15 @@ api.unassignTask = {
|
||||
|
||||
await group.unlinkTask(task, assignedUser);
|
||||
|
||||
let notificationIndex = assignedUser.notifications.findIndex(function findNotification (notification) {
|
||||
return notification && notification.data && notification.type === 'GROUP_TASK_ASSIGNED' && notification.data.taskId === task._id;
|
||||
});
|
||||
|
||||
if (notificationIndex !== -1) {
|
||||
assignedUser.notifications.splice(notificationIndex, 1);
|
||||
await assignedUser.save();
|
||||
}
|
||||
|
||||
res.respond(200, task);
|
||||
},
|
||||
};
|
||||
@@ -315,6 +332,9 @@ api.approveTask = {
|
||||
|
||||
if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
|
||||
if (task.group.approval.approved === true) throw new NotAuthorized(res.t('canOnlyApproveTaskOnce'));
|
||||
if (!task.group.approval.requested) {
|
||||
throw new NotAuthorized(res.t('taskApprovalWasNotRequested'));
|
||||
}
|
||||
|
||||
task.group.approval.dateApproved = new Date();
|
||||
task.group.approval.approvingUser = user._id;
|
||||
|
||||
@@ -16,15 +16,14 @@ import {
|
||||
} from '../../libs/webhook';
|
||||
import {
|
||||
getUserInfo,
|
||||
sendTxn as txnEmail,
|
||||
sendTxn,
|
||||
} from '../../libs/email';
|
||||
import Queue from '../../libs/queue';
|
||||
import * as inboxLib from '../../libs/inbox';
|
||||
import * as userLib from '../../libs/user';
|
||||
import nconf from 'nconf';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL');
|
||||
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
|
||||
const DELETE_CONFIRMATION = 'DELETE';
|
||||
|
||||
/**
|
||||
@@ -62,7 +61,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
|
||||
@@ -291,8 +290,9 @@ api.deleteUser = {
|
||||
await user.remove();
|
||||
|
||||
if (feedback) {
|
||||
txnEmail({email: TECH_ASSISTANCE_EMAIL}, 'admin-feedback', [
|
||||
sendTxn({email: TECH_ASSISTANCE_EMAIL}, 'admin-feedback', [
|
||||
{name: 'PROFILE_NAME', content: user.profile.name},
|
||||
{name: 'USERNAME', content: user.auth.local.username},
|
||||
{name: 'UUID', content: user._id},
|
||||
{name: 'EMAIL', content: getUserInfo(user, ['email']).email},
|
||||
{name: 'FEEDBACK_SOURCE', content: 'from deletion form'},
|
||||
@@ -300,8 +300,6 @@ api.deleteUser = {
|
||||
]);
|
||||
}
|
||||
|
||||
if (feedback) Queue.sendMessage({feedback, username: user.profile.name}, user._id);
|
||||
|
||||
res.analytics.track('account delete', {
|
||||
uuid: user._id,
|
||||
hitType: 'event',
|
||||
@@ -1423,7 +1421,7 @@ api.deleteMessage = {
|
||||
|
||||
await inboxLib.deleteMessage(user, req.params.id);
|
||||
|
||||
res.respond(200, ...[await inboxLib.getUserInbox(user, false)]);
|
||||
res.respond(200, ...[await inboxLib.getUserInbox(user, {asArray: false})]);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
* {
|
||||
|
||||
Reference in New Issue
Block a user