diff --git a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts index 8ffb33958c26..8c1786853dee 100644 --- a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts +++ b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts @@ -31,11 +31,15 @@ export const canDeleteMessageAsync = async (uid: string, { u, rid, ts }: { u: IU if (!allowed) { return false; } - const blockDeleteInMinutes = await getValue('Message_AllowDeleting_BlockDeleteInMinutes'); + const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete'); - if (blockDeleteInMinutes) { - const timeElapsedForMessage = elapsedTime(ts); - return timeElapsedForMessage <= blockDeleteInMinutes; + if (!bypassBlockTimeLimit) { + const blockDeleteInMinutes = await getValue('Message_AllowDeleting_BlockDeleteInMinutes'); + + if (blockDeleteInMinutes) { + const timeElapsedForMessage = elapsedTime(ts); + return timeElapsedForMessage <= blockDeleteInMinutes; + } } const room = await Rooms.findOneById(rid, { fields: { ro: 1, unmuted: 1 } }); diff --git a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts index 90c3bb7b4a7d..16519b9effe5 100644 --- a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts +++ b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts @@ -224,6 +224,7 @@ export const upsertPermissions = async (): Promise => { { _id: 'view-import-operations', roles: ['admin'] }, { _id: 'clear-oembed-cache', roles: ['admin'] }, { _id: 'videoconf-ring-users', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] }, ]; for await (const permission of permissions) { diff --git a/apps/meteor/app/lib/server/methods/updateMessage.js b/apps/meteor/app/lib/server/methods/updateMessage.js index d3c11f06e53f..ce0eb6ebdc97 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.js +++ b/apps/meteor/app/lib/server/methods/updateMessage.js @@ -49,7 +49,9 @@ Meteor.methods({ } const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes'); - if (Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) { + const bypassBlockTimeLimit = hasPermission(Meteor.userId(), 'bypass-time-limit-edit-and-delete'); + + if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) { let currentTsDiff; let msgTs; diff --git a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts index 86ce779378e5..94fdb37fd5ae 100644 --- a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts +++ b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts @@ -153,14 +153,16 @@ Meteor.startup(async function () { if (isRoomFederated(room)) { return message.u._id === Meteor.userId(); } - const hasPermission = hasAtLeastOnePermission('edit-message', message.rid); + const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid); const isEditAllowed = settings.Message_AllowEditing; const editOwn = message.u && message.u._id === Meteor.userId(); - if (!(hasPermission || (isEditAllowed && editOwn))) { + if (!(canEditMessage || (isEditAllowed && editOwn))) { return false; } const blockEditInMinutes = settings.Message_AllowEditing_BlockEditInMinutes; - if (blockEditInMinutes) { + const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete'); + + if (!bypassBlockTimeLimit && blockEditInMinutes) { let msgTs; if (message.ts != null) { msgTs = moment(message.ts); diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index f393252f1354..d52e1d083bfc 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -86,17 +86,19 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return false; } - const hasPermission = hasAtLeastOnePermission('edit-message', message.rid); + const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid); const editAllowed = (settings.get('Message_AllowEditing') as boolean | undefined) ?? false; const editOwn = message?.u && message.u._id === Meteor.userId(); - if (!hasPermission && (!editAllowed || !editOwn)) { + if (!canEditMessage && (!editAllowed || !editOwn)) { return false; } const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes') as number | undefined; + const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete'); + const elapsedMinutes = moment().diff(message.ts, 'minutes'); - if (elapsedMinutes && blockEditInMinutes && elapsedMinutes > blockEditInMinutes) { + if (!bypassBlockTimeLimit && elapsedMinutes && blockEditInMinutes && elapsedMinutes > blockEditInMinutes) { return false; } @@ -206,8 +208,9 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage } const blockDeleteInMinutes = settings.get('Message_AllowDeleting_BlockDeleteInMinutes') as number | undefined; + const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete'); const elapsedMinutes = moment().diff(message.ts, 'minutes'); - const onTimeForDelete = !blockDeleteInMinutes || !elapsedMinutes || elapsedMinutes <= blockDeleteInMinutes; + const onTimeForDelete = bypassBlockTimeLimit || !blockDeleteInMinutes || !elapsedMinutes || elapsedMinutes <= blockDeleteInMinutes; return deleteAllowed && onTimeForDelete; }; diff --git a/apps/meteor/client/methods/updateMessage.ts b/apps/meteor/client/methods/updateMessage.ts index 0f77b000e3ff..c03c188c2a25 100644 --- a/apps/meteor/client/methods/updateMessage.ts +++ b/apps/meteor/client/methods/updateMessage.ts @@ -4,7 +4,7 @@ import { Tracker } from 'meteor/tracker'; import moment from 'moment'; import _ from 'underscore'; -import { hasAtLeastOnePermission } from '../../app/authorization/client'; +import { hasAtLeastOnePermission, hasPermission } from '../../app/authorization/client'; import { ChatMessage } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { t } from '../../app/utils/client'; @@ -23,7 +23,7 @@ Meteor.methods({ if (!originalMessage) { return; } - const hasPermission = hasAtLeastOnePermission('edit-message', message.rid); + const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid); const editAllowed = settings.get('Message_AllowEditing'); let editOwn = false; @@ -42,7 +42,7 @@ Meteor.methods({ return false; } - if (!(hasPermission || (editAllowed && editOwn))) { + if (!(canEditMessage || (editAllowed && editOwn))) { dispatchToastMessage({ type: 'error', message: t('error-action-not-allowed', { action: t('Message_editing') }), @@ -51,7 +51,9 @@ Meteor.methods({ } const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes'); - if (_.isNumber(blockEditInMinutes) && blockEditInMinutes !== 0) { + const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete'); + + if (!bypassBlockTimeLimit && _.isNumber(blockEditInMinutes) && blockEditInMinutes !== 0) { if (originalMessage.ts) { const msgTs = moment(originalMessage.ts); if (msgTs) { diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useMessageDeletionIsAllowed.js b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useMessageDeletionIsAllowed.js index 9b426f3bb5fe..187f74d95f4a 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useMessageDeletionIsAllowed.js +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useMessageDeletionIsAllowed.js @@ -8,6 +8,7 @@ export const useMessageDeletionIsAllowed = (rid, uid) => { const deletionIsEnabled = useSetting('Message_AllowDeleting'); const userHasPermissonToDeleteAny = usePermission('delete-message', rid); const userHasPermissonToDeleteOwn = usePermission('delete-own-message'); + const bypassBlockTimeLimit = usePermission('bypass-time-limit-edit-and-delete'); const blockDeleteInMinutes = useSetting('Message_AllowDeleting_BlockDeleteInMinutes'); const isDeletionAllowed = (() => { @@ -24,7 +25,7 @@ export const useMessageDeletionIsAllowed = (rid, uid) => { } const checkTimeframe = - blockDeleteInMinutes !== 0 + !bypassBlockTimeLimit && blockDeleteInMinutes !== 0 ? ({ ts }) => { if (!ts) { return false; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index ed9dfe605109..1e5b84658266 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -296,6 +296,8 @@ "add-livechat-department-agents_description": "Permission to add omnichannel agents to departments", "add-oauth-service": "Add OAuth Service", "add-oauth-service_description": "Permission to add a new OAuth service", + "bypass-time-limit-edit-and-delete": "Bypass time limit", + "bypass-time-limit-edit-and-delete_description": "Permission to Bypass time limit for editing and deleting messages", "add-team-channel": "Add Team Channel", "add-team-channel_description": "Permission to add a channel to a team", "add-team-member": "Add Team Member", diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts index c534eae0b295..8fce9cd85682 100644 --- a/apps/meteor/server/startup/migrations/index.ts +++ b/apps/meteor/server/startup/migrations/index.ts @@ -44,4 +44,5 @@ import './v282'; import './v283'; import './v284'; import './v285'; +import './v286'; import './xrun'; diff --git a/apps/meteor/server/startup/migrations/v286.ts b/apps/meteor/server/startup/migrations/v286.ts new file mode 100644 index 000000000000..25e7420b1267 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v286.ts @@ -0,0 +1,9 @@ +import { addMigration } from '../../lib/migrations'; +import { upsertPermissions } from '../../../app/authorization/server/functions/upsertPermissions'; + +addMigration({ + version: 286, + up() { + upsertPermissions(); + }, +}); diff --git a/apps/meteor/tests/end-to-end/api/24-methods.js b/apps/meteor/tests/end-to-end/api/24-methods.js index 67d8f8253c87..137d60cbc41a 100644 --- a/apps/meteor/tests/end-to-end/api/24-methods.js +++ b/apps/meteor/tests/end-to-end/api/24-methods.js @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { getCredentials, request, methodCall, api, credentials } from '../../data/api-data.js'; -import { updatePermission } from '../../data/permissions.helper.js'; +import { updatePermission, updateSetting } from '../../data/permissions.helper.js'; describe('Meteor.methods', function () { this.retries(0); @@ -1623,6 +1623,46 @@ describe('Meteor.methods', function () { .end(done); }); + it('should update a message when bypass time limits permission is enabled', async () => { + await Promise.all([ + updatePermission('bypass-time-limit-edit-and-delete', ['admin']), + updateSetting('Message_AllowEditing_BlockEditInMinutes', 0.01), + ]); + + await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'https://github.com updated with bypass' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + }); + + await request + .get(api(`chat.getMessage?msgId=${messageId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').that.is.an('object'); + expect(res.body.message.msg).to.equal('https://github.com updated with bypass'); + }); + + await Promise.all([ + updatePermission('bypass-time-limit-edit-and-delete', ['bot', 'app']), + updateSetting('Message_AllowEditing_BlockEditInMinutes', 0), + ]); + }); + it('should not parse URLs inside markdown on update', (done) => { request .post(methodCall('updateMessage')) @@ -1667,6 +1707,122 @@ describe('Meteor.methods', function () { }); }); + describe('[@deleteMessage]', () => { + let rid = false; + let messageId; + + before('create room', (done) => { + const channelName = `methods-test-channel-${Date.now()}`; + request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + beforeEach('send message with URL', (done) => { + request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: `${Date.now() + Math.random()}`, + rid, + msg: 'test message with https://github.com', + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('urls').that.is.an('array'); + expect(data.result.urls[0].url).to.equal('https://github.com'); + messageId = data.result._id; + }) + .end(done); + }); + + it('should delete a message', (done) => { + request + .post(methodCall('deleteMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'deleteMessage', + params: [{ _id: messageId, rid }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', 'id'); + }) + .end(done); + }); + + it('should delete a message when bypass time limits permission is enabled', async () => { + await Promise.all([ + updatePermission('bypass-time-limit-edit-and-delete', ['admin']), + updateSetting('Message_AllowEditing_BlockEditInMinutes', 0.01), + ]); + + await request + .post(methodCall('deleteMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'deleteMessage', + params: [{ _id: messageId, rid }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', 'id'); + }); + + await Promise.all([ + updatePermission('bypass-time-limit-edit-and-delete', ['bot', 'app']), + updateSetting('Message_AllowEditing_BlockEditInMinutes', 0), + ]); + }); + }); + describe('[@setUserActiveStatus]', () => { let testUser; let testUser2;