diff --git a/.changeset/four-eyes-sniff.md b/.changeset/four-eyes-sniff.md new file mode 100644 index 00000000000..06c9dac06d0 --- /dev/null +++ b/.changeset/four-eyes-sniff.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/i18n": minor +--- + +Allow Custom Fields in Messages. API-only feature. It can be enabled and configured in Workspace Settings. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 98fc278594a..c482e3bb784 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -312,6 +312,7 @@ API.v1.addRoute( roomId: String, msgId: String, text: String, // Using text to be consistant with chat.postMessage + customFields: Match.Maybe(Object), previewUrls: Match.Maybe([String]), }), ); @@ -328,7 +329,16 @@ API.v1.addRoute( } // Permission checks are already done in the updateMessage method, so no need to duplicate them - await executeUpdateMessage(this.userId, { _id: msg._id, msg: this.bodyParams.text, rid: msg.rid }, this.bodyParams.previewUrls); + await executeUpdateMessage( + this.userId, + { + _id: msg._id, + msg: this.bodyParams.text, + rid: msg.rid, + customFields: this.bodyParams.customFields as Record | undefined, + }, + this.bodyParams.previewUrls, + ); const updatedMessage = await Messages.findOneById(msg._id); const [message] = await normalizeMessagesForUser(updatedMessage ? [updatedMessage] : [], this.userId); diff --git a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts index 4dafc621101..2a4303a82dd 100644 --- a/apps/meteor/app/lib/server/functions/processWebhookMessage.ts +++ b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts @@ -25,6 +25,7 @@ type Payload = { bot?: IMessage['bot']; groupable?: IMessage['groupable']; tmid?: IMessage['tmid']; + customFields?: IMessage['customFields']; }; type DefaultValues = { @@ -109,6 +110,7 @@ export const processWebhookMessage = async function ( bot: messageObj.bot, groupable: messageObj.groupable !== undefined ? messageObj.groupable : false, tmid: messageObj.tmid, + customFields: messageObj.customFields, }; if (!_.isEmpty(messageObj.icon_url) || !_.isEmpty(messageObj.avatar)) { diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index c81b553166d..e167b372b00 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -11,6 +11,7 @@ import { broadcastMessageFromData } from '../../../../server/modules/watchers/li import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; +import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; import { parseUrlsInMessage } from './parseUrlsInMessage'; // TODO: most of the types here are wrong, but I don't want to change them now @@ -171,6 +172,14 @@ export const validateMessage = async (message: any, room: any, user: any) => { if (Array.isArray(message.attachments) && message.attachments.length) { validateBodyAttachments(message.attachments); } + + if (message.customFields) { + validateCustomMessageFields({ + customFields: message.customFields, + messageCustomFieldsEnabled: settings.get('Message_CustomFields_Enabled'), + messageCustomFields: settings.get('Message_CustomFields'), + }); + } }; export function prepareMessageObject( diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index 6b3cb9ef64f..8fdfc964db4 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { settings } from '../../../settings/server'; +import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; import { parseUrlsInMessage } from './parseUrlsInMessage'; export const updateMessage = async function ( @@ -59,6 +60,14 @@ export const updateMessage = async function ( messageData = await Message.beforeSave({ message: messageData, room, user }); + if (messageData.customFields) { + validateCustomMessageFields({ + customFields: messageData.customFields, + messageCustomFieldsEnabled: settings.get('Message_CustomFields_Enabled'), + messageCustomFields: settings.get('Message_CustomFields'), + }); + } + const { _id, ...editedMessage } = messageData; if (!editedMessage.msg) { diff --git a/apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts b/apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts new file mode 100644 index 00000000000..b0126fa07ed --- /dev/null +++ b/apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts @@ -0,0 +1,44 @@ +import Ajv from 'ajv'; +import mem from 'mem'; + +const ajv = new Ajv(); + +const customFieldsValidate = mem( + (customFieldsSetting: string) => { + const schema = JSON.parse(customFieldsSetting); + + if (schema.type && schema.type !== 'object') { + throw new Error('Invalid custom fields config'); + } + + return ajv.compile({ + ...schema, + type: 'object', + additionalProperties: false, + }); + }, + { maxAge: 1000 * 60 }, +); + +export const validateCustomMessageFields = ({ + customFields, + messageCustomFieldsEnabled, + messageCustomFields, +}: { + customFields: Record; + messageCustomFieldsEnabled: boolean; + messageCustomFields: string; +}) => { + // get the json schema for the custom fields of the message and validate it using ajv + // if the validation fails, throw an error + // if there are no custom fields, the message object remains unchanged + + if (messageCustomFieldsEnabled !== true) { + throw new Error('Custom fields not enabled'); + } + + const validate = customFieldsValidate(messageCustomFields); + if (!validate(customFields)) { + throw new Error('Invalid custom fields'); + } +}; diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index a3449240013..277841fd58e 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -10,7 +10,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { settings } from '../../../settings/server'; import { updateMessage } from '../functions/updateMessage'; -const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg']; +const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields']; export async function executeUpdateMessage(uid: IUser['_id'], message: AtLeast, previewUrls?: string[]) { const originalMessage = await Messages.findOneById(message._id); diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index e8318957908..6ec6e335565 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -385,4 +385,35 @@ export const createMessageSettings = () => type: 'boolean', public: true, }); + + await this.add('Message_CustomFields_Enabled', false, { + type: 'boolean', + }); + await this.add( + 'Message_CustomFields', + ` +{ + "properties": { + "priority": { + "type": "string", + "nullable": false, + "enum": ["low", "medium", "high"] + } + }, + "required": ["priority"] +} + `, + { + type: 'code', + code: 'application/json', + invalidValue: '', + multiline: true, + enableQuery: [ + { + _id: 'Message_CustomFields_Enabled', + value: true, + }, + ], + }, + ); }); diff --git a/apps/meteor/tests/end-to-end/api/05-chat.js b/apps/meteor/tests/end-to-end/api/05-chat.js index bc7c1166b79..e12bf8138f4 100644 --- a/apps/meteor/tests/end-to-end/api/05-chat.js +++ b/apps/meteor/tests/end-to-end/api/05-chat.js @@ -1101,6 +1101,314 @@ describe('[Chat]', function () { }) .end(done); }); + + describe('customFields', () => { + describe('when disabled', () => { + it('should not allow sending custom fields', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + customFields: { + field1: 'value1', + }, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Custom fields not enabled'); + }); + + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: testChannel._id, + msg: 'Sample message', + customFields: { + field1: 'value1', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Custom fields not enabled'); + }); + }); + + it('should not allow update custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: { + field1: 'value1', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Custom fields not enabled'); + }); + }); + }); + + describe('when enabled', () => { + before(async () => { + await updateSetting('Message_CustomFields_Enabled', true); + await updateSetting( + 'Message_CustomFields', + JSON.stringify({ + properties: { + priority: { + type: 'string', + nullable: false, + enum: ['low', 'medium', 'high'], + }, + }, + required: ['priority'], + }), + ); + }); + + after(async () => { + await updateSetting('Message_CustomFields_Enabled', false); + }); + + it('should allow not sending custom fields', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: testChannel._id, + msg: 'Sample message', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should not allow sending empty custom fields', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + customFields: {}, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: testChannel._id, + msg: 'Sample message', + customFields: {}, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not allow sending wrong custom fields', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + customFields: { + field1: 'value1', + }, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: testChannel._id, + msg: 'Sample message', + customFields: { + field1: 'value1', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should allow sending correct custom fields', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + customFields: { + priority: 'low', + }, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' }); + }); + + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: testChannel._id, + msg: 'Sample message', + customFields: { + priority: 'low', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' }); + }); + }); + + it('should allow not sending custom fields on update', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should not allow update empty custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: {}, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not allow update wrong custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: { + field1: 'value1', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should allow update correct custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: { + priority: 'low', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' }); + }); + }); + }); + }); }); describe('/chat.update', () => { diff --git a/apps/meteor/tests/unit/app/lib/server/lib/validateCustomMessageFields.tests.ts b/apps/meteor/tests/unit/app/lib/server/lib/validateCustomMessageFields.tests.ts new file mode 100644 index 00000000000..d0f8851a78e --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/lib/validateCustomMessageFields.tests.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai'; + +import { validateCustomMessageFields } from '../../../../../../app/lib/server/lib/validateCustomMessageFields'; + +describe('validateCustomMessageFields', () => { + describe('When not enabled', () => { + it('should not allow to pass custom fields', () => { + const customFields = { + test: 'test', + }; + expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: false, messageCustomFields: '' })).to.throw( + 'Custom fields not enabled', + ); + }); + }); + + describe('When enabled', () => { + it('should not allow to pass invalid custom fields config', () => { + const customFields = { + test: 'test', + }; + expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields: '' })).to.throw( + 'Unexpected end of JSON input', + ); + }); + + it('should not allow to pass a property not present in config', () => { + const customFields = { + test: 'test', + }; + const messageCustomFields = JSON.stringify({ + properties: { + priority: { + type: 'string', + }, + }, + additionalProperties: true, + }); + expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw( + 'Invalid custom fields', + ); + }); + + it('should not allow to pass an invalid custom field value', () => { + const customFields = { + test: 123, + }; + const messageCustomFields = JSON.stringify({ + properties: { + priority: { + type: 'string', + }, + }, + additionalProperties: true, + }); + expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw( + 'Invalid custom fields', + ); + }); + + it('should not allow to pass anything different from an object', () => { + const customFields = [1, 2]; + const messageCustomFields = JSON.stringify({ + type: 'array', + items: [{ type: 'integer' }, { type: 'integer' }], + }); + expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw( + 'Invalid custom fields config', + ); + }); + + it('should allow to pass a valid custom fields config', () => { + const customFields = { + test: 'test', + }; + const messageCustomFields = JSON.stringify({ + properties: { + test: { + type: 'string', + }, + }, + }); + expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.not.throw(); + }); + }); +}); diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 1fe87ffe70e..fc73ede6ece 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -127,6 +127,8 @@ export type MessageMention = { fname?: string; // incase of channel mentions }; +export interface IMessageCustomFields {} + export interface IMessage extends IRocketChatRecord { rid: RoomID; msg: string; @@ -219,6 +221,8 @@ export interface IMessage extends IRocketChatRecord { definedBy: Pick; priority?: Pick; }; + + customFields?: IMessageCustomFields; } export type MessageSystem = { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 11ee7667f8f..09945700e21 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3504,6 +3504,9 @@ "Message_Code_highlight_Description": "Comma separated list of languages (all supported languages at [highlight.js](https://github.com/highlightjs/highlight.js/tree/11.6.0#supported-languages)) that will be used to highlight code blocks", "Message_CustomDomain_AutoLink": "Custom Domain Whitelist for Auto Link", "Message_CustomDomain_AutoLink_Description": "If you want to auto link internal links like `https://internaltool.intranet` or `internaltool.intranet`, you need to add the `intranet` domain to the field, multiple domains need to be separated by comma.", + "Message_CustomFields_Enabled": "Allow Custom Fields in Messages", + "Message_CustomFields": "Custom Fields Validation", + "Message_CustomFields_Description": "Custom Fields will be validated according to the rules defined in this setting.\nCheck [ajv.js.org](https://ajv.js.org/json-schema.html) for more information regarding validation options.\nProperties `type` and `additionalProperties` will be forced to `object` and `false` respectively.", "message_counter_one": "{{count}} message", "message_counter_other": "{{count}} messages", "Message_DateFormat": "Date Format", diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 0af9fabced5..ce486da5534 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -63,6 +63,10 @@ const chatSendMessageSchema = { }, nullable: true, }, + customFields: { + type: 'object', + nullable: true, + }, }, }, previewUrls: { @@ -439,6 +443,7 @@ type ChatUpdate = { msgId: string; text: string; previewUrls?: string[]; + customFields: IMessage['customFields']; }; const ChatUpdateSchema = { @@ -460,6 +465,10 @@ const ChatUpdateSchema = { }, nullable: true, }, + customFields: { + type: 'object', + nullable: true, + }, }, required: ['roomId', 'msgId', 'text'], additionalProperties: false, @@ -697,8 +706,24 @@ const ChatGetDeletedMessagesSchema = { export const isChatGetDeletedMessagesProps = ajv.compile(ChatGetDeletedMessagesSchema); type ChatPostMessage = - | { roomId: string | string[]; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] } - | { channel: string | string[]; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] }; + | { + roomId: string | string[]; + text?: string; + alias?: string; + emoji?: string; + avatar?: string; + attachments?: MessageAttachment[]; + customFields?: IMessage['customFields']; + } + | { + channel: string | string[]; + text?: string; + alias?: string; + emoji?: string; + avatar?: string; + attachments?: MessageAttachment[]; + customFields?: IMessage['customFields']; + }; const ChatPostMessageSchema = { oneOf: [ @@ -739,6 +764,10 @@ const ChatPostMessageSchema = { }, nullable: true, }, + customFields: { + type: 'object', + nullable: true, + }, }, required: ['roomId'], additionalProperties: false, @@ -780,6 +809,10 @@ const ChatPostMessageSchema = { }, nullable: true, }, + customFields: { + type: 'object', + nullable: true, + }, }, required: ['channel'], additionalProperties: false,