mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-19 15:48:04 +01:00
Disallow interactions by blocked users; new "get objections" Members API route (#8755)
* Make flags.chatRevoked prevent sending private messages (issue #7971) * Disallow sending gems when messages aren't allowed. * Created function to check for objections to an interaction to user model and wired it into the API (issue #7971) * Fixes for issues raised by reviewers. * Added allowed values to apidoc for api.getObjectionsToInteraction. * Refactoring of getObjectionsToInteraction and minor API changes. * fix(objections): address PR comments * fix(strings): use US English for base edits * refactor(test): typos and phrasing
This commit is contained in:
committed by
Keith Holliday
parent
00e5896ac6
commit
018976a723
@@ -70,9 +70,9 @@ describe('POST /chat', () => {
|
|||||||
it('returns an error when chat privileges are revoked when sending a message to a public guild', async () => {
|
it('returns an error when chat privileges are revoked when sending a message to a public guild', async () => {
|
||||||
let userWithChatRevoked = await member.update({'flags.chatRevoked': true});
|
let userWithChatRevoked = await member.update({'flags.chatRevoked': true});
|
||||||
await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({
|
await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({
|
||||||
code: 404,
|
code: 401,
|
||||||
error: 'NotFound',
|
error: 'NotAuthorized',
|
||||||
message: 'Your chat privileges have been revoked.',
|
message: t('chatPrivilegesRevoked'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
generateUser,
|
||||||
|
translate as t,
|
||||||
|
} from '../../../../helpers/api-v3-integration.helper';
|
||||||
|
import { v4 as generateUUID } from 'uuid';
|
||||||
|
|
||||||
|
describe('GET /members/:toUserId/objections/:interaction', () => {
|
||||||
|
let user;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
user = await generateUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates req.params.memberId', async () => {
|
||||||
|
await expect(
|
||||||
|
user.get('/members/invalidUUID/objections/send-private-message')
|
||||||
|
).to.eventually.be.rejected.and.eql({
|
||||||
|
code: 400,
|
||||||
|
error: 'BadRequest',
|
||||||
|
message: t('invalidReqParams'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-existing members', async () => {
|
||||||
|
let dummyId = generateUUID();
|
||||||
|
await expect(
|
||||||
|
user.get(`/members/${dummyId}/objections/send-private-message`)
|
||||||
|
).to.eventually.be.rejected.and.eql({
|
||||||
|
code: 404,
|
||||||
|
error: 'NotFound',
|
||||||
|
message: t('userWithIDNotFound', {userId: dummyId}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-existing interactions', async () => {
|
||||||
|
let receiver = await generateUser();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
user.get(`/members/${receiver._id}/objections/hug-a-whole-forest-of-trees`)
|
||||||
|
).to.eventually.be.rejected.and.eql({
|
||||||
|
code: 400,
|
||||||
|
error: 'BadRequest',
|
||||||
|
message: t('invalidReqParams'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array if there are no objections', async () => {
|
||||||
|
let receiver = await generateUser();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
user.get(`/members/${receiver._id}/objections/send-private-message`)
|
||||||
|
).to.eventually.be.fulfilled.and.eql([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an array of objections if any exist', async () => {
|
||||||
|
let receiver = await generateUser({'inbox.blocks': [user._id]});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
user.get(`/members/${receiver._id}/objections/send-private-message`)
|
||||||
|
).to.eventually.be.fulfilled.and.eql([
|
||||||
|
t('notAuthorizedToSendMessageToThisUser'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -82,6 +82,20 @@ describe('POST /members/send-private-message', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns an error when chat privileges are revoked', async () => {
|
||||||
|
let userWithChatRevoked = await generateUser({'flags.chatRevoked': true});
|
||||||
|
let receiver = await generateUser();
|
||||||
|
|
||||||
|
await expect(userWithChatRevoked.post('/members/send-private-message', {
|
||||||
|
message: messageToSend,
|
||||||
|
toUserId: receiver._id,
|
||||||
|
})).to.eventually.be.rejected.and.eql({
|
||||||
|
code: 401,
|
||||||
|
error: 'NotAuthorized',
|
||||||
|
message: t('chatPrivilegesRevoked'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('sends a private message to a user', async () => {
|
it('sends a private message to a user', async () => {
|
||||||
let receiver = await generateUser();
|
let receiver = await generateUser();
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('POST /members/transfer-gems', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error when to user is not found', async () => {
|
it('returns error when recipient is not found', async () => {
|
||||||
await expect(userToSendMessage.post('/members/transfer-gems', {
|
await expect(userToSendMessage.post('/members/transfer-gems', {
|
||||||
message,
|
message,
|
||||||
gemAmount,
|
gemAmount,
|
||||||
@@ -55,7 +55,7 @@ describe('POST /members/transfer-gems', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error when to user attempts to send gems to themselves', async () => {
|
it('returns error when user attempts to send gems to themselves', async () => {
|
||||||
await expect(userToSendMessage.post('/members/transfer-gems', {
|
await expect(userToSendMessage.post('/members/transfer-gems', {
|
||||||
message,
|
message,
|
||||||
gemAmount,
|
gemAmount,
|
||||||
@@ -67,6 +67,64 @@ describe('POST /members/transfer-gems', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns error when recipient has blocked the sender', async () => {
|
||||||
|
let receiverWhoBlocksUser = await generateUser({'inbox.blocks': [userToSendMessage._id]});
|
||||||
|
|
||||||
|
await expect(userToSendMessage.post('/members/transfer-gems', {
|
||||||
|
message,
|
||||||
|
gemAmount,
|
||||||
|
toUserId: receiverWhoBlocksUser._id,
|
||||||
|
})).to.eventually.be.rejected.and.eql({
|
||||||
|
code: 401,
|
||||||
|
error: 'NotAuthorized',
|
||||||
|
message: t('notAuthorizedToSendMessageToThisUser'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when sender has blocked recipient', async () => {
|
||||||
|
let sender = await generateUser({'inbox.blocks': [receiver._id]});
|
||||||
|
|
||||||
|
await expect(sender.post('/members/transfer-gems', {
|
||||||
|
message,
|
||||||
|
gemAmount,
|
||||||
|
toUserId: receiver._id,
|
||||||
|
})).to.eventually.be.rejected.and.eql({
|
||||||
|
code: 401,
|
||||||
|
error: 'NotAuthorized',
|
||||||
|
message: t('notAuthorizedToSendMessageToThisUser'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error when chat privileges are revoked', async () => {
|
||||||
|
let userWithChatRevoked = await generateUser({'flags.chatRevoked': true});
|
||||||
|
|
||||||
|
await expect(userWithChatRevoked.post('/members/transfer-gems', {
|
||||||
|
message,
|
||||||
|
gemAmount,
|
||||||
|
toUserId: receiver._id,
|
||||||
|
})).to.eventually.be.rejected.and.eql({
|
||||||
|
code: 401,
|
||||||
|
error: 'NotAuthorized',
|
||||||
|
message: t('chatPrivilegesRevoked'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works when only the recipient\'s chat privileges are revoked', async () => {
|
||||||
|
let receiverWithChatRevoked = await generateUser({'flags.chatRevoked': true});
|
||||||
|
|
||||||
|
await expect(userToSendMessage.post('/members/transfer-gems', {
|
||||||
|
message,
|
||||||
|
gemAmount,
|
||||||
|
toUserId: receiverWithChatRevoked._id,
|
||||||
|
})).to.eventually.be.fulfilled;
|
||||||
|
|
||||||
|
let updatedReceiver = await receiverWithChatRevoked.get('/user');
|
||||||
|
let updatedSender = await userToSendMessage.get('/user');
|
||||||
|
|
||||||
|
expect(updatedReceiver.balance).to.equal(gemAmount / 4);
|
||||||
|
expect(updatedSender.balance).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns error when there is no gemAmount', async () => {
|
it('returns error when there is no gemAmount', async () => {
|
||||||
await expect(userToSendMessage.post('/members/transfer-gems', {
|
await expect(userToSendMessage.post('/members/transfer-gems', {
|
||||||
message,
|
message,
|
||||||
@@ -144,7 +202,7 @@ describe('POST /members/transfer-gems', () => {
|
|||||||
expect(updatedSender.balance).to.equal(0);
|
expect(updatedSender.balance).to.equal(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not requrie a message', async () => {
|
it('does not require a message', async () => {
|
||||||
await userToSendMessage.post('/members/transfer-gems', {
|
await userToSendMessage.post('/members/transfer-gems', {
|
||||||
gemAmount,
|
gemAmount,
|
||||||
toUserId: receiver._id,
|
toUserId: receiver._id,
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
"onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!",
|
"onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!",
|
||||||
"onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!",
|
"onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!",
|
||||||
"onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned",
|
"onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned",
|
||||||
|
"chatPrivilegesRevoked": "Your chat privileges have been revoked.",
|
||||||
"newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!",
|
"newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!",
|
||||||
"newChatMessageTitle": "New message in <%= groupName %>",
|
"newChatMessageTitle": "New message in <%= groupName %>",
|
||||||
"exportInbox": "Export Messages",
|
"exportInbox": "Export Messages",
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ function textContainsBannedWords (message) {
|
|||||||
* @apiParam (Body) {String} message Message The message to post
|
* @apiParam (Body) {String} message Message The message to post
|
||||||
* @apiParam (Query) {UUID} previousMsg The previous chat message's UUID which will force a return of the full group chat
|
* @apiParam (Query) {UUID} previousMsg The previous chat message's UUID which will force a return of the full group chat
|
||||||
*
|
*
|
||||||
* @apiSuccess data An array of <a href='https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js#L51' target='_blank'>chat messages</a> if a new message was posted after previousMsg, otherwise the posted message
|
|
||||||
*
|
|
||||||
* @apiUse GroupNotFound
|
* @apiUse GroupNotFound
|
||||||
* @apiUse GroupIdRequired
|
* @apiUse GroupIdRequired
|
||||||
* @apiError (400) {NotFound} ChatPriviledgesRevoked Your chat privileges have been revoked
|
* @apiError (400) {NotFound} ChatPriviledgesRevoked Your chat privileges have been revoked
|
||||||
@@ -143,7 +141,7 @@ api.postChat = {
|
|||||||
|
|
||||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||||
if (group.privacy !== 'private' && user.flags.chatRevoked) {
|
if (group.privacy !== 'private' && user.flags.chatRevoked) {
|
||||||
throw new NotFound('Your chat privileges have been revoked.');
|
throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group._id === TAVERN_ID && textContainsBannedWords(req.body.message)) {
|
if (group._id === TAVERN_ID && textContainsBannedWords(req.body.message)) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
publicFields as memberFields,
|
publicFields as memberFields,
|
||||||
nameFields,
|
nameFields,
|
||||||
} from '../../models/user';
|
} from '../../models/user';
|
||||||
|
import {
|
||||||
|
KNOWN_INTERACTIONS,
|
||||||
|
} from '../../models/user/methods';
|
||||||
import { model as Group } from '../../models/group';
|
import { model as Group } from '../../models/group';
|
||||||
import { model as Challenge } from '../../models/challenge';
|
import { model as Challenge } from '../../models/challenge';
|
||||||
import {
|
import {
|
||||||
@@ -385,6 +388,39 @@ api.getChallengeMemberProgress = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} /api/v3/members/:toUserId/objections/:interaction Get any objections that would occur if the given interaction was attempted - BETA
|
||||||
|
* @apiVersion 3.0.0
|
||||||
|
* @apiName GetObjectionsToInteraction
|
||||||
|
* @apiGroup Member
|
||||||
|
*
|
||||||
|
* @apiParam {UUID} toUserId The user to interact with
|
||||||
|
* @apiParam {String="send-private-message","transfer-gems"} interaction Name of the interaction to query
|
||||||
|
*
|
||||||
|
* @apiSuccess {Array} data Return an array of objections, if the interaction would be blocked; otherwise an empty array
|
||||||
|
*/
|
||||||
|
api.getObjectionsToInteraction = {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/members/:toUserId/objections/:interaction',
|
||||||
|
middlewares: [authWithHeaders()],
|
||||||
|
async handler (req, res) {
|
||||||
|
req.checkParams('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
|
||||||
|
req.checkParams('interaction', res.t('interactionRequired')).notEmpty().isIn(KNOWN_INTERACTIONS);
|
||||||
|
|
||||||
|
let validationErrors = req.validationErrors();
|
||||||
|
if (validationErrors) throw validationErrors;
|
||||||
|
|
||||||
|
let sender = res.locals.user;
|
||||||
|
let receiver = await User.findById(req.params.toUserId).exec();
|
||||||
|
if (!receiver) throw new NotFound(res.t('userWithIDNotFound', {userId: req.params.toUserId}));
|
||||||
|
|
||||||
|
let interaction = req.params.interaction;
|
||||||
|
let response = sender.getObjectionsToInteraction(interaction, receiver);
|
||||||
|
|
||||||
|
res.respond(200, response.map(res.t));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {posts} /api/v3/members/send-private-message Send a private message to a member
|
* @api {posts} /api/v3/members/send-private-message Send a private message to a member
|
||||||
* @apiName SendPrivateMessage
|
* @apiName SendPrivateMessage
|
||||||
@@ -410,17 +446,11 @@ api.sendPrivateMessage = {
|
|||||||
|
|
||||||
let sender = res.locals.user;
|
let sender = res.locals.user;
|
||||||
let message = req.body.message;
|
let message = req.body.message;
|
||||||
|
|
||||||
let receiver = await User.findById(req.body.toUserId).exec();
|
let receiver = await User.findById(req.body.toUserId).exec();
|
||||||
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
||||||
|
|
||||||
let userBlockedSender = receiver.inbox.blocks.indexOf(sender._id) !== -1;
|
let objections = sender.getObjectionsToInteraction('send-private-message', receiver);
|
||||||
let userIsBlockBySender = sender.inbox.blocks.indexOf(receiver._id) !== -1;
|
if (objections.length > 0) throw new NotAuthorized(res.t(objections[0]));
|
||||||
let userOptedOutOfMessaging = receiver.inbox.optOut;
|
|
||||||
|
|
||||||
if (userBlockedSender || userIsBlockBySender || userOptedOutOfMessaging) {
|
|
||||||
throw new NotAuthorized(res.t('notAuthorizedToSendMessageToThisUser'));
|
|
||||||
}
|
|
||||||
|
|
||||||
await sender.sendMessage(receiver, { receiverMsg: message });
|
await sender.sendMessage(receiver, { receiverMsg: message });
|
||||||
|
|
||||||
@@ -472,13 +502,11 @@ api.transferGems = {
|
|||||||
if (validationErrors) throw validationErrors;
|
if (validationErrors) throw validationErrors;
|
||||||
|
|
||||||
let sender = res.locals.user;
|
let sender = res.locals.user;
|
||||||
|
|
||||||
let receiver = await User.findById(req.body.toUserId).exec();
|
let receiver = await User.findById(req.body.toUserId).exec();
|
||||||
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
||||||
|
|
||||||
if (receiver._id === sender._id) {
|
let objections = sender.getObjectionsToInteraction('transfer-gems', receiver);
|
||||||
throw new NotAuthorized(res.t('cannotSendGemsToYourself'));
|
if (objections.length > 0) throw new NotAuthorized(res.t(objections[0]));
|
||||||
}
|
|
||||||
|
|
||||||
let gemAmount = req.body.gemAmount;
|
let gemAmount = req.body.gemAmount;
|
||||||
let amount = gemAmount / 4;
|
let amount = gemAmount / 4;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
chatDefaults,
|
chatDefaults,
|
||||||
TAVERN_ID,
|
TAVERN_ID,
|
||||||
} from '../group';
|
} from '../group';
|
||||||
import { defaults } from 'lodash';
|
import { defaults, map, flatten, flow, compact, uniq, partialRight } from 'lodash';
|
||||||
import { model as UserNotification } from '../userNotification';
|
import { model as UserNotification } from '../userNotification';
|
||||||
import schema from './schema';
|
import schema from './schema';
|
||||||
import payments from '../../libs/payments';
|
import payments from '../../libs/payments';
|
||||||
@@ -33,6 +33,56 @@ schema.methods.getGroups = function getUserGroups () {
|
|||||||
return userGroups;
|
return userGroups;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */ // The checks below all get access to sndr and rcvr, but not all use both
|
||||||
|
const INTERACTION_CHECKS = Object.freeze({
|
||||||
|
always: [
|
||||||
|
// Revoked chat privileges block all interactions to prevent the evading of harassment protections
|
||||||
|
// See issue #7971 for some discussion
|
||||||
|
(sndr, rcvr) => sndr.flags.chatRevoked && 'chatPrivilegesRevoked',
|
||||||
|
|
||||||
|
// Direct user blocks prevent all interactions
|
||||||
|
(sndr, rcvr) => rcvr.inbox.blocks.includes(sndr._id) && 'notAuthorizedToSendMessageToThisUser',
|
||||||
|
(sndr, rcvr) => sndr.inbox.blocks.includes(rcvr._id) && 'notAuthorizedToSendMessageToThisUser',
|
||||||
|
],
|
||||||
|
|
||||||
|
'send-private-message': [
|
||||||
|
// Private messaging has an opt-out, which does not affect other interactions
|
||||||
|
(sndr, rcvr) => rcvr.inbox.optOut && 'notAuthorizedToSendMessageToThisUser',
|
||||||
|
|
||||||
|
// We allow a player to message themselves so they can test how PMs work or send their own notes to themselves
|
||||||
|
],
|
||||||
|
|
||||||
|
'transfer-gems': [
|
||||||
|
// Unlike private messages, gems can't be sent to oneself
|
||||||
|
(sndr, rcvr) => rcvr._id === sndr._id && 'cannotSendGemsToYourself',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
/* eslint-enable no-unused-vars */
|
||||||
|
|
||||||
|
export const KNOWN_INTERACTIONS = Object.freeze(Object.keys(INTERACTION_CHECKS).filter(key => key !== 'always'));
|
||||||
|
|
||||||
|
// Get an array of error message keys that would be thrown if the given interaction was attempted
|
||||||
|
schema.methods.getObjectionsToInteraction = function getObjectionsToInteraction (interaction, receiver) {
|
||||||
|
if (!KNOWN_INTERACTIONS.includes(interaction)) {
|
||||||
|
throw new Error(`Unknown kind of interaction: "${interaction}", expected one of ${KNOWN_INTERACTIONS.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = this;
|
||||||
|
let checks = [
|
||||||
|
INTERACTION_CHECKS.always,
|
||||||
|
INTERACTION_CHECKS[interaction],
|
||||||
|
];
|
||||||
|
|
||||||
|
let executeChecks = partialRight(map, (check) => check(sender, receiver));
|
||||||
|
|
||||||
|
return flow(
|
||||||
|
flatten,
|
||||||
|
executeChecks,
|
||||||
|
compact, // Remove passed checks (passed checks return falsy; failed checks return message keys)
|
||||||
|
uniq
|
||||||
|
)(checks);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a message to a user. Archives a copy in sender's inbox.
|
* Sends a message to a user. Archives a copy in sender's inbox.
|
||||||
|
|||||||
Reference in New Issue
Block a user