diff --git a/app/authorization/client/hasPermission.js b/app/authorization/client/hasPermission.js index 7820772cd454..994c363428d9 100644 --- a/app/authorization/client/hasPermission.js +++ b/app/authorization/client/hasPermission.js @@ -4,7 +4,9 @@ import { Template } from 'meteor/templating'; import { ChatPermissions } from './lib/ChatPermissions'; import * as Models from '../../models'; -function atLeastOne(permissions = [], scope) { +function atLeastOne(permissions = [], scope, userId) { + userId = userId || Meteor.userId(); + return permissions.some((permissionId) => { const permission = ChatPermissions.findOne(permissionId, { fields: { roles: 1 } }); const roles = (permission && permission.roles) || []; @@ -14,12 +16,14 @@ function atLeastOne(permissions = [], scope) { const roleScope = role && role.scope; const model = Models[roleScope]; - return model && model.isUserInRole && model.isUserInRole(Meteor.userId(), roleName, scope); + return model && model.isUserInRole && model.isUserInRole(userId, roleName, scope); }); }); } -function all(permissions = [], scope) { +function all(permissions = [], scope, userId) { + userId = userId || Meteor.userId(); + return permissions.every((permissionId) => { const permission = ChatPermissions.findOne(permissionId, { fields: { roles: 1 } }); const roles = (permission && permission.roles) || []; @@ -29,13 +33,13 @@ function all(permissions = [], scope) { const roleScope = role && role.scope; const model = Models[roleScope]; - return model && model.isUserInRole && model.isUserInRole(Meteor.userId(), roleName, scope); + return model && model.isUserInRole && model.isUserInRole(userId, roleName, scope); }); }); } -function _hasPermission(permissions, scope, strategy) { - const userId = Meteor.userId(); +function _hasPermission(permissions, scope, strategy, userId) { + userId = userId || Meteor.userId(); if (!userId) { return false; } @@ -45,13 +49,17 @@ function _hasPermission(permissions, scope, strategy) { } permissions = [].concat(permissions); - return strategy(permissions, scope); + return strategy(permissions, scope, userId); } Template.registerHelper('hasPermission', function(permission, scope) { return _hasPermission(permission, scope, atLeastOne); }); +Template.registerHelper('userHasAllPermission', function(userId, permission, scope) { + return _hasPermission(permission, scope, all, userId); +}); export const hasAllPermission = (permissions, scope) => _hasPermission(permissions, scope, all); export const hasAtLeastOnePermission = (permissions, scope) => _hasPermission(permissions, scope, atLeastOne); +export const userHasAllPermission = (permissions, scope, userId) => _hasPermission(permissions, scope, all, userId); export const hasPermission = hasAllPermission; diff --git a/app/authorization/client/index.js b/app/authorization/client/index.js index a45f3463243a..709df4e2f38c 100644 --- a/app/authorization/client/index.js +++ b/app/authorization/client/index.js @@ -1,4 +1,4 @@ -import { hasAllPermission, hasAtLeastOnePermission, hasPermission } from './hasPermission'; +import { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } from './hasPermission'; import { hasRole } from './hasRole'; import './usersNameChanged'; import './requiresPermission.html'; @@ -14,4 +14,5 @@ export { hasAtLeastOnePermission, hasRole, hasPermission, + userHasAllPermission, }; diff --git a/app/authorization/server/functions/canSendMessage.js b/app/authorization/server/functions/canSendMessage.js index 96fa41ef3259..7ca6a41d3646 100644 --- a/app/authorization/server/functions/canSendMessage.js +++ b/app/authorization/server/functions/canSendMessage.js @@ -1,8 +1,13 @@ import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { Random } from 'meteor/random'; import { canAccessRoom } from './canAccessRoom'; +import { hasPermission } from './hasPermission'; +import { Notifications } from '../../../notifications'; import { Rooms, Subscriptions } from '../../../models'; + export const canSendMessage = (rid, { uid, username }, extraData) => { const room = Rooms.findOneById(rid); @@ -15,6 +20,22 @@ export const canSendMessage = (rid, { uid, username }, extraData) => { throw new Meteor.Error('room_is_blocked'); } + if (room.ro === true) { + if (!hasPermission(Meteor.userId(), 'post-readonly', room._id)) { + // Unless the user was manually unmuted + if (!(room.unmuted || []).includes(username)) { + Notifications.notifyUser(Meteor.userId(), 'message', { + _id: Random.id(), + rid: room._id, + ts: new Date(), + msg: TAPi18n.__('room_is_read_only'), + }); + + throw new Meteor.Error('You can\'t send messages because the room is readonly.'); + } + } + } + if ((room.muted || []).includes(username)) { throw new Meteor.Error('You_have_been_muted'); } diff --git a/app/lib/server/functions/addUserToDefaultChannels.js b/app/lib/server/functions/addUserToDefaultChannels.js index c7a2a0b2c5b5..bef1ba47620d 100644 --- a/app/lib/server/functions/addUserToDefaultChannels.js +++ b/app/lib/server/functions/addUserToDefaultChannels.js @@ -1,17 +1,10 @@ import { Rooms, Subscriptions, Messages } from '../../../models'; -import { hasPermission } from '../../../authorization'; import { callbacks } from '../../../callbacks'; export const addUserToDefaultChannels = function(user, silenced) { callbacks.run('beforeJoinDefaultChannels', user); const defaultRooms = Rooms.findByDefaultAndTypes(true, ['c', 'p'], { fields: { usernames: 0 } }).fetch(); defaultRooms.forEach((room) => { - // put user in default rooms - const muted = room.ro && !hasPermission(user._id, 'post-readonly'); - if (muted) { - Rooms.muteUsernameByRoomId(room._id, user.username); - } - if (!Subscriptions.findOneByRoomIdAndUserId(room._id, user._id)) { // Add a subscription to this user Subscriptions.createWithRoomAndUser(room, user, { diff --git a/app/lib/server/functions/addUserToRoom.js b/app/lib/server/functions/addUserToRoom.js index 09da986640cf..39db45fb4143 100644 --- a/app/lib/server/functions/addUserToRoom.js +++ b/app/lib/server/functions/addUserToRoom.js @@ -1,7 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { Rooms, Subscriptions, Messages } from '../../../models'; -import { hasPermission } from '../../../authorization'; import { callbacks } from '../../../callbacks'; export const addUserToRoom = function(rid, user, inviter, silenced) { @@ -22,11 +21,6 @@ export const addUserToRoom = function(rid, user, inviter, silenced) { callbacks.run('beforeJoinRoom', user, room); } - const muted = room.ro && !hasPermission(user._id, 'post-readonly'); - if (muted) { - Rooms.muteUsernameByRoomId(rid, user.username); - } - Subscriptions.createWithRoomAndUser(room, user, { ts: now, open: true, diff --git a/app/lib/server/functions/createRoom.js b/app/lib/server/functions/createRoom.js index 445bc7f913c1..31eb76285e17 100644 --- a/app/lib/server/functions/createRoom.js +++ b/app/lib/server/functions/createRoom.js @@ -4,7 +4,7 @@ import s from 'underscore.string'; import { Users, Rooms, Subscriptions } from '../../../models'; import { callbacks } from '../../../callbacks'; -import { hasPermission, addUserRoles } from '../../../authorization'; +import { addUserRoles } from '../../../authorization'; import { getValidRoomName } from '../../../utils'; import { Apps } from '../../../apps/server'; @@ -132,16 +132,10 @@ export const createRoom = function(type, name, owner, members, readOnly, extraDa for (const username of members) { const member = Users.findOneByUsername(username, { fields: { username: 1, 'settings.preferences': 1 } }); - const isTheOwner = username === owner.username; if (!member) { continue; } - // make all room members (Except the owner) muted by default, unless they have the post-readonly permission - if (readOnly === true && !hasPermission(member._id, 'post-readonly') && !isTheOwner) { - Rooms.muteUsernameByRoomId(room._id, username); - } - const extra = options.subscriptionExtra || {}; extra.open = true; diff --git a/app/lib/server/functions/notifications/mobile.js b/app/lib/server/functions/notifications/mobile.js index 58d1aabc2c71..fef2c83ba2b5 100644 --- a/app/lib/server/functions/notifications/mobile.js +++ b/app/lib/server/functions/notifications/mobile.js @@ -33,8 +33,17 @@ async function getBadgeCount(userId) { return total; } -function canSendMessageToRoom(room, username) { - return !(room.muted || []).includes(username); +function enableNotificationReplyButton(room, username) { + // Some users may have permission to send messages even on readonly rooms, but we're ok with false negatives here in exchange of better perfomance + if (room.ro === true) { + return false; + } + + if (!room.muted) { + return true; + } + + return !room.muted.includes(username); } export async function sendSinglePush({ room, message, userId, receiverUsername, senderUsername, senderName, notificationMessage }) { @@ -61,7 +70,7 @@ export async function sendSinglePush({ room, message, userId, receiverUsername, usersTo: { userId, }, - category: canSendMessageToRoom(room, receiverUsername) ? CATEGORY_MESSAGE : CATEGORY_MESSAGE_NOREPLY, + category: enableNotificationReplyButton(room, receiverUsername) ? CATEGORY_MESSAGE : CATEGORY_MESSAGE_NOREPLY, }); } diff --git a/app/lib/server/lib/processDirectEmail.js b/app/lib/server/lib/processDirectEmail.js index f9394dbd2c11..4489551698e6 100644 --- a/app/lib/server/lib/processDirectEmail.js +++ b/app/lib/server/lib/processDirectEmail.js @@ -5,6 +5,7 @@ import moment from 'moment'; import { settings } from '../../../settings'; import { Rooms, Messages, Users, Subscriptions } from '../../../models'; import { metrics } from '../../../metrics'; +import { hasPermission } from '../../../authorization'; import { sendMessage as _sendMessage } from '../functions'; export const processDirectEmail = function(email) { @@ -87,10 +88,20 @@ export const processDirectEmail = function(email) { } if ((room.muted || []).includes(user.username)) { - // room is muted + // user is muted return false; } + // room is readonly + if (room.ro === true) { + if (!hasPermission(Meteor.userId(), 'post-readonly', room._id)) { + // Check if the user was manually unmuted + if (!(room.unmuted || []).includes(user.username)) { + return false; + } + } + } + if (message.alias == null && settings.get('Message_SetNameToAliasEnabled')) { message.alias = user.name; } diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index fee61378f8b8..a07574ee1ae7 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -544,25 +544,8 @@ export class Rooms extends Base { const update = { $set: { ro: readOnly, - muted: [], }, }; - if (readOnly) { - Subscriptions.findByRoomIdWhenUsernameExists(_id, { fields: { 'u._id': 1, 'u.username': 1 } }).forEach(function({ u: user }) { - if (hasPermission(user._id, 'post-readonly')) { - return; - } - return update.$set.muted.push(user.username); - }); - } else { - update.$unset = { - muted: '', - }; - } - - if (update.$set.muted.length === 0) { - delete update.$set.muted; - } return this.update(query, update); } @@ -1177,6 +1160,9 @@ export class Rooms extends Base { $addToSet: { muted: username, }, + $pull: { + unmuted: username, + }, }; return this.update(query, update); @@ -1189,6 +1175,9 @@ export class Rooms extends Base { $pull: { muted: username, }, + $addToSet: { + unmuted: username, + }, }; return this.update(query, update); diff --git a/app/reactions/client/init.js b/app/reactions/client/init.js index 942f61b80ff8..5e52a9b82f7c 100644 --- a/app/reactions/client/init.js +++ b/app/reactions/client/init.js @@ -17,7 +17,13 @@ Template.room.events({ const user = Meteor.user(); const room = Rooms.findOne({ _id: rid }); - if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !room.reactWhenReadOnly) { + if (room.ro && !room.reactWhenReadOnly) { + if (!Array.isArray(room.unmuted) || room.unmuted.indexOf(user.username) === -1) { + return false; + } + } + + if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1) { return false; } @@ -68,11 +74,23 @@ Meteor.startup(function() { if (!room) { return false; - } if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !room.reactWhenReadOnly) { + } + + if (room.ro && !room.reactWhenReadOnly) { + if (!Array.isArray(room.unmuted) || room.unmuted.indexOf(user.username) === -1) { + return false; + } + } + + if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1) { return false; - } if (!Subscriptions.findOne({ rid: message.rid })) { + } + + if (!Subscriptions.findOne({ rid: message.rid })) { return false; - } if (message.private) { + } + + if (message.private) { return false; } diff --git a/app/reactions/client/methods/setReaction.js b/app/reactions/client/methods/setReaction.js index ea031039dafe..285dd9ce18ca 100644 --- a/app/reactions/client/methods/setReaction.js +++ b/app/reactions/client/methods/setReaction.js @@ -16,13 +16,25 @@ Meteor.methods({ const message = Messages.findOne({ _id: messageId }); const room = Rooms.findOne({ _id: message.rid }); - if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !room.reactWhenReadOnly) { + if (room.ro && !room.reactWhenReadOnly) { + if (!Array.isArray(room.unmuted) || room.unmuted.indexOf(user.username) === -1) { + return false; + } + } + + if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1) { return false; - } if (!Subscriptions.findOne({ rid: message.rid })) { + } + + if (!Subscriptions.findOne({ rid: message.rid })) { return false; - } if (message.private) { + } + + if (message.private) { return false; - } if (!emoji.list[reaction] && EmojiCustom.findByNameOrAlias(reaction).count() === 0) { + } + + if (!emoji.list[reaction] && EmojiCustom.findByNameOrAlias(reaction).count() === 0) { return false; } diff --git a/app/reactions/server/setReaction.js b/app/reactions/server/setReaction.js index 9a608de89345..14fb9fdaaa5b 100644 --- a/app/reactions/server/setReaction.js +++ b/app/reactions/server/setReaction.js @@ -24,7 +24,13 @@ export function setReaction(room, user, message, reaction, shouldReact) { throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { method: 'setReaction' }); } - if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !room.reactWhenReadOnly) { + if (room.ro && !room.reactWhenReadOnly) { + if (!Array.isArray(room.unmuted) || room.unmuted.indexOf(user.username) === -1) { + return false; + } + } + + if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1) { Notifications.notifyUser(Meteor.userId(), 'message', { _id: Random.id(), rid: room._id, diff --git a/app/ui-admin/server/publications/adminRooms.js b/app/ui-admin/server/publications/adminRooms.js index f40e92f00e31..bd8971e6df4d 100644 --- a/app/ui-admin/server/publications/adminRooms.js +++ b/app/ui-admin/server/publications/adminRooms.js @@ -25,6 +25,7 @@ Meteor.publish('adminRooms', function(filter, types, limit) { usernames: 1, usersCount: 1, muted: 1, + unmuted: 1, ro: 1, default: 1, topic: 1, diff --git a/app/ui-flextab/client/tabs/membersList.js b/app/ui-flextab/client/tabs/membersList.js index 1b62302acd46..eb955d1ae0f8 100644 --- a/app/ui-flextab/client/tabs/membersList.js +++ b/app/ui-flextab/client/tabs/membersList.js @@ -34,6 +34,7 @@ Template.membersList.helpers({ const roomUsers = Template.instance().users.get(); const room = ChatRoom.findOne(this.rid); const roomMuted = (room != null ? room.muted : undefined) || []; + const roomUnmuted = (room != null ? room.unmuted : undefined) || []; const userUtcOffset = Meteor.user() && Meteor.user().utcOffset; let totalOnline = 0; let users = roomUsers; @@ -66,10 +67,12 @@ Template.membersList.helpers({ } } + const muted = (room.ro && !roomUnmuted.includes(user.username)) || roomMuted.includes(user.username); + return { user, status: onlineUsers[user.username] != null ? onlineUsers[user.username].status : 'offline', - muted: Array.from(roomMuted).includes(user.username), + muted, utcOffset, }; }); diff --git a/app/ui-flextab/client/tabs/userActions.js b/app/ui-flextab/client/tabs/userActions.js index c543da623709..2e5657cd0809 100644 --- a/app/ui-flextab/client/tabs/userActions.js +++ b/app/ui-flextab/client/tabs/userActions.js @@ -3,13 +3,14 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { Session } from 'meteor/session'; import { TAPi18n } from 'meteor/tap:i18n'; import toastr from 'toastr'; +import _ from 'underscore'; import { WebRTC } from '../../../webrtc/client'; import { ChatRoom, ChatSubscription, RoomRoles, Subscriptions } from '../../../models'; import { modal } from '../../../ui-utils'; import { t, handleError, roomTypes } from '../../../utils'; import { settings } from '../../../settings'; -import { hasPermission, hasAllPermission, hasRole } from '../../../authorization'; +import { hasPermission, hasAllPermission, hasRole, userHasAllPermission } from '../../../authorization'; const canSetLeader = () => hasAllPermission('set-leader', Session.get('openedRoom')); @@ -53,6 +54,19 @@ export const getActions = ({ user, directActions, hideAdminControls }) => { const isMuted = () => { const room = ChatRoom.findOne(Session.get('openedRoom')); + + if (room && room.ro) { + if (_.isArray(room.unmuted) && room.unmuted.indexOf(user && user.username) !== -1) { + return false; + } + + if (userHasAllPermission(user._id, 'post-readonly', room)) { + return _.isArray(room.muted) && (room.muted.indexOf(user && user.username) !== -1); + } + + return true; + } + return room && Array.isArray(room.muted) && room.muted.indexOf(user && user.username) > -1; }; diff --git a/app/utils/client/lib/roomTypes.js b/app/utils/client/lib/roomTypes.js index 96fdcf0e5493..887d82c791b6 100644 --- a/app/utils/client/lib/roomTypes.js +++ b/app/utils/client/lib/roomTypes.js @@ -2,7 +2,8 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import _ from 'underscore'; import { RoomTypesCommon } from '../../lib/RoomTypesCommon'; -import { ChatRoom, ChatSubscription, RoomRoles } from '../../../models'; +import { hasAtLeastOnePermission } from '../../../authorization'; +import { ChatRoom, ChatSubscription } from '../../../models'; export const roomTypes = new class RocketChatRoomTypes extends RoomTypesCommon { checkCondition(roomType) { @@ -65,6 +66,7 @@ export const roomTypes = new class RocketChatRoomTypes extends RoomTypesCommon { }; if (user) { fields.muted = 1; + fields.unmuted = 1; } const room = ChatRoom.findOne({ _id: rid, @@ -80,16 +82,26 @@ export const roomTypes = new class RocketChatRoomTypes extends RoomTypesCommon { if (!user) { return room && room.ro; } - const userOwner = RoomRoles.findOne({ - rid, - 'u._id': user._id, - roles: 'owner', - }, { - fields: { - _id: 1, - }, - }); - return room && (room.ro === true && Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !userOwner); + + if (room) { + if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1) { + return true; + } + + if (room.ro === true) { + if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user.username) !== -1) { + return false; + } + + if (hasAtLeastOnePermission('post-readonly', room._id)) { + return false; + } + + return true; + } + } + + return false; } archived(rid) { diff --git a/server/methods/unmuteUserInRoom.js b/server/methods/unmuteUserInRoom.js index 759c21483909..4c4e8db537a9 100644 --- a/server/methods/unmuteUserInRoom.js +++ b/server/methods/unmuteUserInRoom.js @@ -2,8 +2,8 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { hasPermission } from '../../app/authorization'; -import { Users, Subscriptions, Rooms, Messages } from '../../app/models'; import { callbacks } from '../../app/callbacks'; +import { Rooms, Subscriptions, Users, Messages } from '../../app/models'; Meteor.methods({ unmuteUserInRoom(data) { diff --git a/server/publications/room.js b/server/publications/room.js index 570e04ea9063..5df737755eeb 100644 --- a/server/publications/room.js +++ b/server/publications/room.js @@ -19,6 +19,7 @@ const fields = { announcement: 1, announcementDetails: 1, muted: 1, + unmuted: 1, _updatedAt: 1, archived: 1, jitsiTimeout: 1, diff --git a/server/startup/migrations/v146.js b/server/startup/migrations/v146.js new file mode 100644 index 000000000000..f617c6ce8825 --- /dev/null +++ b/server/startup/migrations/v146.js @@ -0,0 +1,23 @@ +import { Migrations } from '../../../app/migrations/server'; +import { Rooms } from '../../../app/models'; + +Migrations.add({ + version: 146, + up() { + Rooms.update({ + ro: true, + muted: { + $exists: true, + $not: { + $size: 0, + }, + }, + }, { + $set: { + muted: [], + }, + }, { + multi: true, + }); + }, +});