From d49c97587e19e4ddd1c44dda7e1a30b50c3ec645 Mon Sep 17 00:00:00 2001
From: Luciano Marcos Pierdona Junior
 <64279791+LucianoPierdona@users.noreply.github.com>
Date: Thu, 9 Feb 2023 20:41:40 -0300
Subject: [PATCH] [NEW] Permission to bypass message editing and removing
 limits (#27644)

Co-authored-by: Hugo Costa <20212776+hugocostadev@users.noreply.github.com>
---
 .../server/functions/canDeleteMessage.ts      |  12 +-
 .../server/functions/upsertPermissions.ts     |   1 +
 .../app/lib/server/methods/updateMessage.js   |   4 +-
 .../client/lib/messageActionDefault.ts        |   8 +-
 apps/meteor/client/lib/chats/data.ts          |  11 +-
 apps/meteor/client/methods/updateMessage.ts   |  10 +-
 .../hooks/useMessageDeletionIsAllowed.js      |   3 +-
 .../rocketchat-i18n/i18n/en.i18n.json         |   2 +
 .../meteor/server/startup/migrations/index.ts |   1 +
 apps/meteor/server/startup/migrations/v286.ts |   9 +
 .../meteor/tests/end-to-end/api/24-methods.js | 158 +++++++++++++++++-
 11 files changed, 201 insertions(+), 18 deletions(-)
 create mode 100644 apps/meteor/server/startup/migrations/v286.ts

diff --git a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts
index 8ffb33958c2..8c1786853de 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 90c3bb7b4a7..16519b9effe 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<void> => {
 		{ _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 d3c11f06e53..ce0eb6ebdc9 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 86ce779378e..94fdb37fd5a 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 f393252f135..d52e1d083bf 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 0f77b000e3f..c03c188c2a2 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 9b426f3bb5f..187f74d95f4 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 ed9dfe60510..1e5b8465826 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 c534eae0b29..8fce9cd8568 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 00000000000..25e7420b126
--- /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 67d8f8253c8..137d60cbc41 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;
-- 
GitLab