From defc4b39cb742928e22b2c47f540aef444c73a2e Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Tue, 26 Sep 2023 10:25:54 -0300 Subject: [PATCH 1/6] fix: Microsoft autotranslate not working (#30390) --- apps/meteor/app/autotranslate/server/msTranslate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/autotranslate/server/msTranslate.ts b/apps/meteor/app/autotranslate/server/msTranslate.ts index 3e9c9dbd8a35..f885a23b8e6b 100644 --- a/apps/meteor/app/autotranslate/server/msTranslate.ts +++ b/apps/meteor/app/autotranslate/server/msTranslate.ts @@ -87,7 +87,7 @@ class MsAutoTranslate extends AutoTranslate { if (this.supportedLanguages[target]) { return this.supportedLanguages[target]; } - const request = await fetch(this.apiEndPointUrl); + const request = await fetch(this.apiGetLanguages); if (!request.ok) { throw new Error(request.statusText); } From 8a02759e40bf7c9aba55fc17baf87257a203a8cd Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Tue, 26 Sep 2023 19:13:25 -0300 Subject: [PATCH 2/6] fix: do not broadcast events from the local node to the local service (duplicated event) (#30446) --- .changeset/brave-snakes-scream.md | 5 +++++ apps/meteor/ee/server/local-services/instance/service.ts | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/brave-snakes-scream.md diff --git a/.changeset/brave-snakes-scream.md b/.changeset/brave-snakes-scream.md new file mode 100644 index 000000000000..914f248cd821 --- /dev/null +++ b/.changeset/brave-snakes-scream.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where broadcasted events were published twice within the same instance diff --git a/apps/meteor/ee/server/local-services/instance/service.ts b/apps/meteor/ee/server/local-services/instance/service.ts index 0fc4fd33a9b1..85c021f74769 100644 --- a/apps/meteor/ee/server/local-services/instance/service.ts +++ b/apps/meteor/ee/server/local-services/instance/service.ts @@ -96,6 +96,12 @@ export class InstanceService extends ServiceClassInternal implements IInstanceSe events: { broadcast(ctx: any) { const { eventName, streamName, args } = ctx.params; + const { nodeID } = ctx; + + const fromLocalNode = nodeID === InstanceStatus.id(); + if (fromLocalNode) { + return; + } const instance = StreamerCentral.instances[streamName]; if (!instance) { From 92613680b7ea8fbd60983476a632e6b48f4f8204 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 26 Sep 2023 20:05:21 -0300 Subject: [PATCH 3/6] feat: use isolated-vm to run integration scripts (#30229) Co-authored-by: Marcos Spessatto Defendi Co-authored-by: Tasso Evangelista --- .changeset/thirty-pumpkins-fix.md | 8 + apps/meteor/.docker/Dockerfile.alpine | 5 + .../meteor/app/integrations/server/api/api.js | 155 +----- .../integrations/server/lib/ScriptEngine.ts | 385 +++++++++++++++ .../app/integrations/server/lib/definition.ts | 19 + .../server/lib/isolated-vm/buildSandbox.ts | 127 +++++ .../lib/isolated-vm/getCompatibilityScript.ts | 60 +++ .../server/lib/isolated-vm/isolated-vm.ts | 99 ++++ .../integrations/server/lib/triggerHandler.js | 448 +++--------------- .../integrations/server/lib/updateHistory.ts | 96 ++++ .../server/lib/validateOutgoingIntegration.ts | 16 +- .../server/lib/validateScriptEngine.ts | 26 + .../server/lib/vm2/buildSandbox.ts | 88 ++++ .../app/integrations/server/lib/vm2/vm2.ts | 111 +++++ .../incoming/addIncomingIntegration.ts | 17 +- .../incoming/updateIncomingIntegration.ts | 26 +- .../outgoing/addOutgoingIntegration.ts | 8 +- .../outgoing/updateOutgoingIntegration.ts | 22 +- .../admin/integrations/IncomingWebhookForm.js | 26 +- .../integrations/OutgoiongWebhookForm.js | 21 + .../integrations/edit/EditIncomingWebhook.js | 1 + .../integrations/edit/EditOutgoingWebhook.js | 1 + .../integrations/new/NewIncomingWebhook.js | 1 + .../integrations/new/NewOutgoingWebhook.js | 1 + apps/meteor/package.json | 1 + .../rocketchat-i18n/i18n/en.i18n.json | 6 + packages/core-typings/src/IIntegration.ts | 6 + .../core-typings/src/IIntegrationHistory.ts | 7 +- .../integrations/IntegrationsCreateProps.ts | 12 +- packages/tools/src/index.ts | 1 + packages/tools/src/wrapExceptions.ts | 46 ++ yarn.lock | 10 + 32 files changed, 1306 insertions(+), 550 deletions(-) create mode 100644 .changeset/thirty-pumpkins-fix.md create mode 100644 apps/meteor/app/integrations/server/lib/ScriptEngine.ts create mode 100644 apps/meteor/app/integrations/server/lib/definition.ts create mode 100644 apps/meteor/app/integrations/server/lib/isolated-vm/buildSandbox.ts create mode 100644 apps/meteor/app/integrations/server/lib/isolated-vm/getCompatibilityScript.ts create mode 100644 apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts create mode 100644 apps/meteor/app/integrations/server/lib/updateHistory.ts create mode 100644 apps/meteor/app/integrations/server/lib/validateScriptEngine.ts create mode 100644 apps/meteor/app/integrations/server/lib/vm2/buildSandbox.ts create mode 100644 apps/meteor/app/integrations/server/lib/vm2/vm2.ts create mode 100644 packages/tools/src/wrapExceptions.ts diff --git a/.changeset/thirty-pumpkins-fix.md b/.changeset/thirty-pumpkins-fix.md new file mode 100644 index 000000000000..11b92b064e15 --- /dev/null +++ b/.changeset/thirty-pumpkins-fix.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/tools': minor +'@rocket.chat/meteor': minor +--- + +Added option to select between two script engine options for the integrations diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index 62a0476d9077..003baa57aa8b 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -15,6 +15,11 @@ RUN set -x \ && npm install sharp@0.30.4 \ && mv node_modules/sharp npm/node_modules/sharp \ # End hack for sharp + # Start hack for isolated-vm... + && rm -rf npm/node_modules/isolated-vm \ + && npm install isolated-vm@4.4.2 \ + && mv node_modules/isolated-vm npm/node_modules/isolated-vm \ + # End hack for isolated-vm && cd npm \ && npm rebuild bcrypt --build-from-source \ && npm cache clear --force \ diff --git a/apps/meteor/app/integrations/server/api/api.js b/apps/meteor/app/integrations/server/api/api.js index e1db46729011..5162fa54ad9c 100644 --- a/apps/meteor/app/integrations/server/api/api.js +++ b/apps/meteor/app/integrations/server/api/api.js @@ -1,114 +1,21 @@ import { Integrations, Users } from '@rocket.chat/models'; -import * as Models from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; -import { Livechat } from 'meteor/rocketchat:livechat'; -import moment from 'moment'; import _ from 'underscore'; -import { VM, VMScript } from 'vm2'; -import * as s from '../../../../lib/utils/stringUtils'; -import { deasyncPromise } from '../../../../server/deasync/deasync'; -import { httpCall } from '../../../../server/lib/http/call'; import { API, APIClass, defaultRateLimiterOptions } from '../../../api/server'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { settings } from '../../../settings/server'; +import { IsolatedVMScriptEngine } from '../lib/isolated-vm/isolated-vm'; +import { VM2ScriptEngine } from '../lib/vm2/vm2'; import { incomingLogger } from '../logger'; import { addOutgoingIntegration } from '../methods/outgoing/addOutgoingIntegration'; import { deleteOutgoingIntegration } from '../methods/outgoing/deleteOutgoingIntegration'; -const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); +const vm2Engine = new VM2ScriptEngine(true); +const ivmEngine = new IsolatedVMScriptEngine(true); -export const forbiddenModelMethods = ['registerModel', 'getCollectionName']; - -const compiledScripts = {}; -function buildSandbox(store = {}) { - const httpAsync = async (method, url, options) => { - try { - return { - result: await httpCall(method, url, options), - }; - } catch (error) { - return { error }; - } - }; - - const sandbox = { - scriptTimeout(reject) { - return setTimeout(() => reject('timed out'), 3000); - }, - _, - s, - console, - moment, - Promise, - Livechat, - Store: { - set(key, val) { - store[key] = val; - return val; - }, - get(key) { - return store[key]; - }, - }, - HTTP: (method, url, options) => { - // TODO: deprecate, track and alert - return deasyncPromise(httpAsync(method, url, options)); - }, - // TODO: Export fetch as the non deprecated method - }; - Object.keys(Models) - .filter((k) => !forbiddenModelMethods.includes(k)) - .forEach((k) => { - sandbox[k] = Models[k]; - }); - return { store, sandbox }; -} - -function getIntegrationScript(integration) { - if (DISABLE_INTEGRATION_SCRIPTS) { - throw API.v1.failure('integration-scripts-disabled'); - } - - const compiledScript = compiledScripts[integration._id]; - if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { - return compiledScript.script; - } - - const script = integration.scriptCompiled; - const { sandbox, store } = buildSandbox(); - try { - incomingLogger.info({ msg: 'Will evaluate script of Trigger', integration: integration.name }); - incomingLogger.debug(script); - - const vmScript = new VMScript(`${script}; Script;`, 'script.js'); - const vm = new VM({ - sandbox, - }); - - const ScriptClass = vm.run(vmScript); - - if (ScriptClass) { - compiledScripts[integration._id] = { - script: new ScriptClass(), - store, - _updatedAt: integration._updatedAt, - }; - - return compiledScripts[integration._id].script; - } - } catch (err) { - incomingLogger.error({ - msg: 'Error evaluating Script in Trigger', - integration: integration.name, - script, - err, - }); - throw API.v1.failure('error-evaluating-script'); - } - - incomingLogger.error({ msg: 'Class "Script" not in Trigger', integration: integration.name }); - throw API.v1.failure('class-script-not-found'); +function getEngine(integration) { + return integration.scriptEngine === 'isolated-vm' ? ivmEngine : vm2Engine; } async function createIntegration(options, user) { @@ -178,20 +85,9 @@ async function executeIntegrationRest() { emoji: this.integration.emoji, }; - if ( - !DISABLE_INTEGRATION_SCRIPTS && - this.integration.scriptEnabled && - this.integration.scriptCompiled && - this.integration.scriptCompiled.trim() !== '' - ) { - let script; - try { - script = getIntegrationScript(this.integration); - } catch (e) { - incomingLogger.error(e); - return API.v1.failure(e.message); - } + const scriptEngine = getEngine(this.integration); + if (scriptEngine.integrationHasValidScript(this.integration)) { this.request.setEncoding('utf8'); const content_raw = this.request.read(); @@ -216,37 +112,12 @@ async function executeIntegrationRest() { }, }; - try { - const { sandbox } = buildSandbox(compiledScripts[this.integration._id].store); - sandbox.script = script; - sandbox.request = request; - - const vm = new VM({ - timeout: 3000, - sandbox, - }); - - const result = await new Promise((resolve, reject) => { - process.nextTick(async () => { - try { - const scriptResult = await vm.run(` - new Promise((resolve, reject) => { - scriptTimeout(reject); - try { - resolve(script.process_incoming_request({ request: request })); - } catch(e) { - reject(e); - } - }).catch((error) => { throw new Error(error); }); - `); - - resolve(scriptResult); - } catch (e) { - reject(e); - } - }); - }); + const result = await scriptEngine.processIncomingRequest({ + integration: this.integration, + request, + }); + try { if (!result) { incomingLogger.debug({ msg: 'Process Incoming Request result of Trigger has no data', diff --git a/apps/meteor/app/integrations/server/lib/ScriptEngine.ts b/apps/meteor/app/integrations/server/lib/ScriptEngine.ts new file mode 100644 index 000000000000..e46984a893ef --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/ScriptEngine.ts @@ -0,0 +1,385 @@ +import type { + IUser, + IRoom, + IMessage, + IOutgoingIntegration, + IIncomingIntegration, + IIntegration, + IIntegrationHistory, +} from '@rocket.chat/core-typings'; +import type { Logger } from '@rocket.chat/logger'; +import type { serverFetch } from '@rocket.chat/server-fetch'; +import { wrapExceptions } from '@rocket.chat/tools'; + +import { incomingLogger, outgoingLogger } from '../logger'; +import type { IScriptClass, CompiledScript } from './definition'; +import { updateHistory } from './updateHistory'; + +type OutgoingRequestBaseData = { + token: IOutgoingIntegration['token']; + bot: false; + trigger_word: string; +}; + +type OutgoingRequestSendMessageData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + message_id: string; + timestamp: Date; + user_id: string; + user_name: string; + text: string; + siteUrl: string; + alias?: string; + bot?: boolean; + isEdited?: true; + tmid?: string; +}; + +type OutgoingRequestUploadedFileData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + message_id: string; + timestamp: Date; + user_id: string; + user_name: string; + text: string; + + user: IUser; + room: IRoom; + message: IMessage; + + alias?: string; + bot?: boolean; +}; + +type OutgoingRequestRoomCreatedData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + timestamp: Date; + user_id: string; + user_name: string; + owner: IUser; + room: IRoom; +}; + +type OutgoingRequestRoomData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + timestamp: Date; + user_id: string; + user_name: string; + owner: IUser; + room: IRoom; + bot?: boolean; +}; + +type OutgoingRequestUserCreatedData = OutgoingRequestBaseData & { + timestamp: Date; + user_id: string; + user_name: string; + user: IUser; + bot?: boolean; +}; + +type OutgoingRequestData = + | OutgoingRequestSendMessageData + | OutgoingRequestUploadedFileData + | OutgoingRequestRoomCreatedData + | OutgoingRequestRoomData + | OutgoingRequestUserCreatedData; + +type OutgoingRequest = { + params: Record; + method: 'POST'; + url: string; + data: OutgoingRequestData; + auth: undefined; + headers: Record; +}; + +type OutgoingRequestFromScript = { + url?: string; + headers?: Record; + method?: string; + message?: { + text?: string; + channel?: string; + attachments?: { + color?: string; + author_name?: string; + author_link?: string; + author_icon?: string; + title?: string; + title_link?: string; + text?: string; + fields?: { + title?: string; + value?: string; + short?: boolean; + }[]; + image_url?: string; + thumb_url?: string; + }[]; + }; + + auth?: string; + data?: Record; +}; + +type OutgoingRequestContext = { + integration: IOutgoingIntegration; + data: OutgoingRequestData; + historyId: IIntegrationHistory['_id']; + url: string; +}; + +type ProcessedOutgoingRequest = OutgoingRequest | OutgoingRequestFromScript; + +type OutgoingResponseContext = { + integration: IOutgoingIntegration; + request: ProcessedOutgoingRequest; + response: Awaited>; + content: string; + historyId: IIntegrationHistory['_id']; +}; + +type IncomingIntegrationRequest = { + url: { + hash: string | null | undefined; + search: string | null | undefined; + query: Record; + pathname: string | null | undefined; + path: string | null | undefined; + }; + url_raw: string; + url_params: Record; + content: Record; + content_raw: string; + headers: Record; + body: Record; + user: Pick, '_id' | 'name' | 'username'>; +}; + +export abstract class IntegrationScriptEngine { + protected compiledScripts: Record; + + public get disabled(): boolean { + return this.isDisabled(); + } + + public get incoming(): IsIncoming { + return this.isIncoming; + } + + constructor(private isIncoming: IsIncoming) { + this.compiledScripts = {}; + } + + public integrationHasValidScript(integration: IIntegration): boolean { + return Boolean(!this.disabled && integration.scriptEnabled && integration.scriptCompiled && integration.scriptCompiled.trim() !== ''); + } + + // PrepareOutgoingRequest will execute a script to build the request object that will be used for the actual integration request + // It may also return a message object to be sent to the room where the integration was triggered + public async prepareOutgoingRequest({ integration, data, historyId, url }: OutgoingRequestContext): Promise { + const request: OutgoingRequest = { + params: {}, + method: 'POST', + url, + data, + auth: undefined, + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36', + }, + }; + + if (!(await this.hasScriptAndMethod(integration, 'prepare_outgoing_request'))) { + return request; + } + + return this.executeOutgoingScript(integration, 'prepare_outgoing_request', { request }, historyId); + } + + public async processOutgoingResponse({ + integration, + request, + response, + content, + historyId, + }: OutgoingResponseContext): Promise { + if (!(await this.hasScriptAndMethod(integration, 'process_outgoing_response'))) { + return; + } + + const sandbox = { + request, + response: { + error: null, + status_code: response.status, + content, + content_raw: content, + headers: Object.fromEntries(response.headers), + }, + }; + + const scriptResult = await this.executeOutgoingScript(integration, 'process_outgoing_response', sandbox, historyId); + + if (scriptResult === false) { + return scriptResult; + } + + if (scriptResult?.content) { + return scriptResult.content; + } + } + + public async processIncomingRequest({ + integration, + request, + }: { + integration: IIncomingIntegration; + request: IncomingIntegrationRequest; + }): Promise { + return this.executeIncomingScript(integration, 'process_incoming_request', { request }); + } + + protected get logger(): ReturnType { + if (this.isIncoming) { + return incomingLogger; + } + + return outgoingLogger; + } + + protected async executeOutgoingScript( + integration: IOutgoingIntegration, + method: keyof IScriptClass, + params: Record, + historyId: IIntegrationHistory['_id'], + ): Promise { + if (this.disabled) { + return; + } + + const script = await wrapExceptions(() => this.getIntegrationScript(integration)).suppress((e: any) => + updateHistory({ + historyId, + step: 'execute-script-getting-script', + error: true, + errorStack: e, + }), + ); + + if (!script) { + return; + } + + if (!script[method]) { + this.logger.error(`Method "${method}" not found in the Integration "${integration.name}"`); + await updateHistory({ historyId, step: `execute-script-no-method-${method}` }); + return; + } + + try { + await updateHistory({ historyId, step: `execute-script-before-running-${method}` }); + + const result = await this.runScriptMethod({ + integrationId: integration._id, + script, + method, + params, + }); + + this.logger.debug({ + msg: `Script method "${method}" result of the Integration "${integration.name}" is:`, + result, + }); + + return result; + } catch (err: any) { + await updateHistory({ + historyId, + step: `execute-script-error-running-${method}`, + error: true, + errorStack: err.stack.replace(/^/gm, ' '), + }); + this.logger.error({ + msg: 'Error running Script in the Integration', + integration: integration.name, + err, + }); + this.logger.debug({ + msg: 'Error running Script in the Integration', + integration: integration.name, + script: integration.scriptCompiled, + }); + } + } + + protected async executeIncomingScript( + integration: IIncomingIntegration, + method: keyof IScriptClass, + params: Record, + ): Promise { + if (!this.integrationHasValidScript(integration)) { + return; + } + + const script = await wrapExceptions(() => this.getIntegrationScript(integration)).catch((e) => { + this.logger.error(e); + throw e; + }); + + if (!script[method]) { + this.logger.error(`Method "${method}" not found in the Integration "${integration.name}"`); + return; + } + + return wrapExceptions(() => + this.runScriptMethod({ + integrationId: integration._id, + script, + method, + params, + }), + ).catch((err: any) => { + this.logger.error({ + msg: 'Error running Script in Trigger', + integration: integration.name, + script: integration.scriptCompiled, + err, + }); + throw new Error('error-running-script'); + }); + } + + protected async hasScriptAndMethod(integration: IIntegration, method: keyof IScriptClass): Promise { + const script = await this.getScriptSafely(integration); + return typeof script?.[method] === 'function'; + } + + protected async getScriptSafely(integration: IIntegration): Promise | undefined> { + if (this.disabled || integration.scriptEnabled !== true || !integration.scriptCompiled || integration.scriptCompiled.trim() === '') { + return; + } + + return wrapExceptions(() => this.getIntegrationScript(integration)).suppress(); + } + + protected abstract isDisabled(): boolean; + + protected abstract runScriptMethod({ + integrationId, + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: IScriptClass; + method: keyof IScriptClass; + params: Record; + }): Promise; + + protected abstract getIntegrationScript(integration: IIntegration): Promise>; +} diff --git a/apps/meteor/app/integrations/server/lib/definition.ts b/apps/meteor/app/integrations/server/lib/definition.ts new file mode 100644 index 000000000000..b4d11b9f4e8b --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/definition.ts @@ -0,0 +1,19 @@ +import type { IIntegration } from '@rocket.chat/core-typings'; + +export interface IScriptClass { + prepare_outgoing_request?: (params: Record) => any; + process_outgoing_response?: (params: Record) => any; + process_incoming_request?: (params: Record) => any; +} + +export type FullScriptClass = Required; + +export type CompiledScript = { + script: Partial; + store: Record; + _updatedAt: IIntegration['_updatedAt']; +}; + +export type CompatibilityScriptResult = IScriptClass & { + availableFunctions: (keyof IScriptClass)[]; +}; diff --git a/apps/meteor/app/integrations/server/lib/isolated-vm/buildSandbox.ts b/apps/meteor/app/integrations/server/lib/isolated-vm/buildSandbox.ts new file mode 100644 index 000000000000..1bbefb6a2ee7 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/isolated-vm/buildSandbox.ts @@ -0,0 +1,127 @@ +import { EventEmitter } from 'events'; + +import { serverFetch as fetch, Response } from '@rocket.chat/server-fetch'; +import ivm, { type Context } from 'isolated-vm'; + +import * as s from '../../../../../lib/utils/stringUtils'; + +const proxyObject = (obj: Record, forbiddenKeys: string[] = []): Record => { + return copyObject({ + isProxy: true, + get: (key: string) => { + if (forbiddenKeys.includes(key)) { + return undefined; + } + + const value = obj[key]; + + if (typeof value === 'function') { + return new ivm.Reference(async (...args: any[]) => { + const result = (obj[key] as any)(...args); + + if (result && result instanceof Promise) { + return new Promise(async (resolve, reject) => { + try { + const awaitedResult = await result; + resolve(makeTransferable(awaitedResult)); + } catch (e) { + reject(e); + } + }); + } + + return makeTransferable(result); + }); + } + + return makeTransferable(value); + }, + }); +}; + +const copyObject = (obj: Record | any[]): Record | any[] => { + if (Array.isArray(obj)) { + return obj.map((data) => copyData(data)); + } + + if (obj instanceof Response) { + return proxyObject(obj, ['clone']); + } + + if (isSemiTransferable(obj)) { + return obj; + } + + if (typeof obj[Symbol.iterator as any] === 'function') { + return copyObject(Array.from(obj as any)); + } + + if (obj instanceof EventEmitter) { + return {}; + } + + const keys = Object.keys(obj); + + return { + ...Object.fromEntries( + keys.map((key) => { + const data = obj[key]; + + if (typeof data === 'function') { + return [key, new ivm.Callback((...args: any[]) => obj[key](...args))]; + } + + return [key, copyData(data)]; + }), + ), + }; +}; + +// Transferable data can be passed to isolates directly +const isTransferable = (data: any): data is ivm.Transferable => { + const dataType = typeof data; + + if (data === ivm) { + return true; + } + + if (['null', 'undefined', 'string', 'number', 'boolean', 'function'].includes(dataType)) { + return true; + } + + if (dataType !== 'object') { + return false; + } + + return ( + data instanceof ivm.Isolate || + data instanceof ivm.Context || + data instanceof ivm.Script || + data instanceof ivm.ExternalCopy || + data instanceof ivm.Callback || + data instanceof ivm.Reference + ); +}; + +// Semi-transferable data can be copied with an ivm.ExternalCopy without needing any manipulation. +const isSemiTransferable = (data: any) => data instanceof ArrayBuffer; + +const copyData = | any[]>(data: T) => (isTransferable(data) ? data : copyObject(data)); +const makeTransferable = (data: any) => (isTransferable(data) ? data : new ivm.ExternalCopy(copyObject(data)).copyInto()); + +export const buildSandbox = (context: Context) => { + const { global: jail } = context; + jail.setSync('global', jail.derefInto()); + jail.setSync('ivm', ivm); + + jail.setSync('s', makeTransferable(s)); + jail.setSync('console', makeTransferable(console)); + + jail.setSync( + 'serverFetch', + new ivm.Reference(async (url: string, ...args: any[]) => { + const result = await fetch(url, ...args); + return makeTransferable(result); + }), + ); +}; diff --git a/apps/meteor/app/integrations/server/lib/isolated-vm/getCompatibilityScript.ts b/apps/meteor/app/integrations/server/lib/isolated-vm/getCompatibilityScript.ts new file mode 100644 index 000000000000..77ce2475e8c2 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/isolated-vm/getCompatibilityScript.ts @@ -0,0 +1,60 @@ +export const getCompatibilityScript = (customScript?: string) => ` + const Store = (function() { + const store = {}; + return { + set(key, val) { + store[key] = val; + return val; + }, + get(key) { + return store[key]; + }, + }; + })(); + + const reproxy = (reference) => { + return new Proxy(reference, { + get(target, p, receiver) { + if (target !== reference || p === 'then') { + return Reflect.get(target, p, receiver); + } + + const data = reference.get(p); + + if (typeof data === 'object' && data instanceof ivm.Reference && data.typeof === 'function') { + return (...args) => data.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + } + + return data; + } + }); + }; + + //url, options, allowSelfSignedCertificate + const fetch = async (...args) => { + const result = await serverFetch.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + + if (result && typeof result === 'object' && result.isProxy) { + return reproxy(result); + } + + return result; + }; + + ${customScript} + + (function() { + const instance = new Script(); + + const functions = { + ...(typeof instance['prepare_outgoing_request'] === 'function' ? { prepare_outgoing_request : (...args) => instance.prepare_outgoing_request(...args) } : {}), + ...(typeof instance['process_outgoing_response'] === 'function' ? { process_outgoing_response : (...args) => instance.process_outgoing_response(...args) } : {}), + ...(typeof instance['process_incoming_request'] === 'function' ? { process_incoming_request : (...args) => instance.process_incoming_request(...args) } : {}), + }; + + return { + ...functions, + availableFunctions: Object.keys(functions), + } + })(); +`; diff --git a/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts b/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts new file mode 100644 index 000000000000..2c78b6d98a7c --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/isolated-vm/isolated-vm.ts @@ -0,0 +1,99 @@ +import type { IIntegration, ValueOf } from '@rocket.chat/core-typings'; +import { pick } from '@rocket.chat/tools'; +import ivm, { type Reference } from 'isolated-vm'; + +import { IntegrationScriptEngine } from '../ScriptEngine'; +import type { IScriptClass, CompatibilityScriptResult, FullScriptClass } from '../definition'; +import { buildSandbox } from './buildSandbox'; +import { getCompatibilityScript } from './getCompatibilityScript'; + +const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true', 'ivm'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); + +export class IsolatedVMScriptEngine extends IntegrationScriptEngine { + protected isDisabled(): boolean { + return DISABLE_INTEGRATION_SCRIPTS; + } + + protected async callScriptFunction( + scriptReference: Reference>, + ...params: Parameters> + ): Promise { + return scriptReference.applySync(undefined, params, { + arguments: { copy: true }, + result: { copy: true, promise: true }, + }); + } + + protected async runScriptMethod({ + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: Partial; + method: keyof IScriptClass; + params: Record; + }): Promise { + const fn = script[method]; + + if (typeof fn !== 'function') { + throw new Error('integration-method-not-found'); + } + + return fn(params); + } + + protected async getIntegrationScript(integration: IIntegration): Promise> { + if (this.disabled) { + throw new Error('integration-scripts-disabled'); + } + + const compiledScript = this.compiledScripts[integration._id]; + if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { + return compiledScript.script; + } + + const script = integration.scriptCompiled; + try { + this.logger.info({ msg: 'Will evaluate the integration script', integration: pick(integration, 'name', '_id') }); + this.logger.debug(script); + + const isolate = new ivm.Isolate({ memoryLimit: 8 }); + + const ivmScript = await isolate.compileScript(getCompatibilityScript(script)); + + const ivmContext = isolate.createContextSync(); + buildSandbox(ivmContext); + + const ivmResult: Reference = await ivmScript.run(ivmContext, { + reference: true, + timeout: 3000, + }); + + const availableFunctions = await ivmResult.get('availableFunctions', { copy: true }); + const scriptFunctions = Object.fromEntries( + availableFunctions.map((functionName) => { + const fnReference = ivmResult.getSync(functionName, { reference: true }); + return [functionName, (...params: Parameters>) => this.callScriptFunction(fnReference, ...params)]; + }), + ) as Partial; + + this.compiledScripts[integration._id] = { + script: scriptFunctions, + store: {}, + _updatedAt: integration._updatedAt, + }; + + return scriptFunctions; + } catch (err: any) { + this.logger.error({ + msg: 'Error evaluating integration script', + integration: integration.name, + script, + err, + }); + + throw new Error('error-evaluating-script'); + } + } +} diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.js b/apps/meteor/app/integrations/server/lib/triggerHandler.js index b122b22ff355..b5050b8c4716 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.js +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.js @@ -1,30 +1,25 @@ -import { Integrations, IntegrationHistory, Users, Rooms, Messages } from '@rocket.chat/models'; -import * as Models from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; +import { Integrations, Users, Rooms, Messages } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { wrapExceptions } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; -import moment from 'moment'; import _ from 'underscore'; -import { VM, VMScript } from 'vm2'; -import { omit } from '../../../../lib/utils/omit'; -import * as s from '../../../../lib/utils/stringUtils'; -import { deasyncPromise } from '../../../../server/deasync/deasync'; -import { httpCall } from '../../../../server/lib/http/call'; import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions/getRoomByNameOrIdWithOptionToJoin'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { settings } from '../../../settings/server'; import { outgoingEvents } from '../../lib/outgoingEvents'; -import { forbiddenModelMethods } from '../api/api'; import { outgoingLogger } from '../logger'; - -const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); +import { IsolatedVMScriptEngine } from './isolated-vm/isolated-vm'; +import { updateHistory } from './updateHistory'; +import { VM2ScriptEngine } from './vm2/vm2'; class RocketChatIntegrationHandler { constructor() { this.successResults = [200, 201, 202]; this.compiledScripts = {}; this.triggers = {}; + this.vm2Engine = new VM2ScriptEngine(false); + this.ivmEngine = new IsolatedVMScriptEngine(false); } addIntegration(record) { @@ -51,6 +46,10 @@ class RocketChatIntegrationHandler { } } + getEngine(integration) { + return integration.scriptEngine === 'isolated-vm' ? this.ivmEngine : this.vm2Engine; + } + removeIntegration(record) { for (const trigger of Object.values(this.triggers)) { delete trigger[record._id]; @@ -67,114 +66,6 @@ class RocketChatIntegrationHandler { return false; } - async updateHistory({ - historyId, - step, - integration, - event, - data, - triggerWord, - ranPrepareScript, - prepareSentMessage, - processSentMessage, - resultMessage, - finished, - url, - httpCallData, - httpError, - httpResult, - error, - errorStack, - }) { - const history = { - type: 'outgoing-webhook', - step, - }; - - // Usually is only added on initial insert - if (integration) { - history.integration = integration; - } - - // Usually is only added on initial insert - if (event) { - history.event = event; - } - - if (data) { - history.data = { ...data }; - - if (data.user) { - history.data.user = omit(data.user, 'services'); - } - - if (data.room) { - history.data.room = data.room; - } - } - - if (triggerWord) { - history.triggerWord = triggerWord; - } - - if (typeof ranPrepareScript !== 'undefined') { - history.ranPrepareScript = ranPrepareScript; - } - - if (prepareSentMessage) { - history.prepareSentMessage = prepareSentMessage; - } - - if (processSentMessage) { - history.processSentMessage = processSentMessage; - } - - if (resultMessage) { - history.resultMessage = resultMessage; - } - - if (typeof finished !== 'undefined') { - history.finished = finished; - } - - if (url) { - history.url = url; - } - - if (typeof httpCallData !== 'undefined') { - history.httpCallData = httpCallData; - } - - if (httpError) { - history.httpError = httpError; - } - - if (typeof httpResult !== 'undefined') { - history.httpResult = JSON.stringify(httpResult, null, 2); - } - - if (typeof error !== 'undefined') { - history.error = error; - } - - if (typeof errorStack !== 'undefined') { - history.errorStack = errorStack; - } - - if (historyId) { - await IntegrationHistory.updateOne({ _id: historyId }, { $set: history }); - return historyId; - } - - history._createdAt = new Date(); - - const _id = Random.id(); - - await IntegrationHistory.insertOne({ _id, ...history }); - - return _id; - } - // Trigger is the trigger, nameOrId is a string which is used to try and find a room, room is a room, message is a message, and data contains "user_name" if trigger.impersonateUser is truthful. async sendMessage({ trigger, nameOrId = '', room, message, data }) { let user; @@ -229,199 +120,6 @@ class RocketChatIntegrationHandler { return message; } - buildSandbox(store = {}) { - const httpAsync = async (method, url, options) => { - try { - return { - result: await httpCall(method, url, options), - }; - } catch (error) { - return { error }; - } - }; - - const sandbox = { - scriptTimeout(reject) { - return setTimeout(() => reject('timed out'), 3000); - }, - _, - s, - console, - moment, - Promise, - Store: { - set: (key, val) => { - store[key] = val; - }, - get: (key) => store[key], - }, - HTTP: (method, url, options) => { - // TODO: deprecate, track and alert - return deasyncPromise(httpAsync(method, url, options)); - }, - // TODO: Export fetch as the non deprecated method - }; - - Object.keys(Models) - .filter((k) => !forbiddenModelMethods.includes(k)) - .forEach((k) => { - sandbox[k] = Models[k]; - }); - - return { store, sandbox }; - } - - getIntegrationScript(integration) { - if (DISABLE_INTEGRATION_SCRIPTS) { - throw new Meteor.Error('integration-scripts-disabled'); - } - - const compiledScript = this.compiledScripts[integration._id]; - if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { - return compiledScript.script; - } - - const script = integration.scriptCompiled; - const { store, sandbox } = this.buildSandbox(); - - try { - outgoingLogger.info({ msg: 'Will evaluate script of Trigger', integration: integration.name }); - outgoingLogger.debug(script); - - const vmScript = new VMScript(`${script}; Script;`, 'script.js'); - const vm = new VM({ - sandbox, - }); - - const ScriptClass = vm.run(vmScript); - - if (ScriptClass) { - this.compiledScripts[integration._id] = { - script: new ScriptClass(), - store, - _updatedAt: integration._updatedAt, - }; - - return this.compiledScripts[integration._id].script; - } - } catch (err) { - outgoingLogger.error({ - msg: 'Error evaluating Script in Trigger', - integration: integration.name, - script, - err, - }); - throw new Meteor.Error('error-evaluating-script'); - } - - outgoingLogger.error(`Class "Script" not in Trigger ${integration.name}:`); - throw new Meteor.Error('class-script-not-found'); - } - - hasScriptAndMethod(integration, method) { - if ( - DISABLE_INTEGRATION_SCRIPTS || - integration.scriptEnabled !== true || - !integration.scriptCompiled || - integration.scriptCompiled.trim() === '' - ) { - return false; - } - - let script; - try { - script = this.getIntegrationScript(integration); - } catch (e) { - return false; - } - - return typeof script[method] !== 'undefined'; - } - - async executeScript(integration, method, params, historyId) { - if (DISABLE_INTEGRATION_SCRIPTS) { - return; - } - - let script; - try { - script = this.getIntegrationScript(integration); - } catch (e) { - await this.updateHistory({ - historyId, - step: 'execute-script-getting-script', - error: true, - errorStack: e, - }); - return; - } - - if (!script[method]) { - outgoingLogger.error(`Method "${method}" no found in the Integration "${integration.name}"`); - await this.updateHistory({ historyId, step: `execute-script-no-method-${method}` }); - return; - } - - try { - const { sandbox } = this.buildSandbox(this.compiledScripts[integration._id].store); - sandbox.script = script; - sandbox.method = method; - sandbox.params = params; - - await this.updateHistory({ historyId, step: `execute-script-before-running-${method}` }); - - const vm = new VM({ - timeout: 3000, - sandbox, - }); - - const result = await new Promise((resolve, reject) => { - process.nextTick(async () => { - try { - const scriptResult = await vm.run(` - new Promise((resolve, reject) => { - scriptTimeout(reject); - try { - resolve(script[method](params)) - } catch(e) { - reject(e); - } - }).catch((error) => { throw new Error(error); }); - `); - - resolve(scriptResult); - } catch (e) { - reject(e); - } - }); - }); - - outgoingLogger.debug({ - msg: `Script method "${method}" result of the Integration "${integration.name}" is:`, - result, - }); - - return result; - } catch (err) { - await this.updateHistory({ - historyId, - step: `execute-script-error-running-${method}`, - error: true, - errorStack: err.stack.replace(/^/gm, ' '), - }); - outgoingLogger.error({ - msg: 'Error running Script in the Integration', - integration: integration.name, - err, - }); - outgoingLogger.debug({ - msg: 'Error running Script in the Integration', - integration: integration.name, - script: integration.scriptCompiled, - }); // Only output the compiled script if debugging is enabled, so the logs don't get spammed. - } - } - eventNameArgumentsToObject(...args) { const argObject = { event: args[0], @@ -680,6 +378,17 @@ class RocketChatIntegrationHandler { } } + // Ensure that any errors thrown by the script engine will contibue to be compatible with Meteor.Error + async wrapScriptEngineCall(getter) { + return wrapExceptions(getter).catch((error) => { + if (error instanceof Error) { + throw new Meteor.Error(error.message); + } + + throw error; + }); + } + async executeTriggerUrl(url, trigger, { event, message, room, owner, user }, theHistoryId, tries = 0) { if (!this.isTriggerEnabled(trigger)) { outgoingLogger.warn(`The trigger "${trigger.name}" is no longer enabled, stopping execution of it at try: ${tries}`); @@ -715,7 +424,7 @@ class RocketChatIntegrationHandler { return; } - const historyId = await this.updateHistory({ + const historyId = await updateHistory({ step: 'start-execute-trigger-url', integration: trigger, event, @@ -731,36 +440,32 @@ class RocketChatIntegrationHandler { } this.mapEventArgsToData(data, { trigger, event, message, room, owner, user }); - await this.updateHistory({ historyId, step: 'mapped-args-to-data', data, triggerWord: word }); + await updateHistory({ historyId, step: 'mapped-args-to-data', data, triggerWord: word }); outgoingLogger.info(`Will be executing the Integration "${trigger.name}" to the url: ${url}`); outgoingLogger.debug({ data }); - let opts = { - params: {}, - method: 'POST', - url, - data, - auth: undefined, - headers: { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36', - }, - }; + const scriptEngine = this.getEngine(trigger); - if (this.hasScriptAndMethod(trigger, 'prepare_outgoing_request')) { - opts = await this.executeScript(trigger, 'prepare_outgoing_request', { request: opts }, historyId); - } + const opts = await this.wrapScriptEngineCall(() => + scriptEngine.prepareOutgoingRequest({ + integration: trigger, + data, + url, + historyId, + }), + ); - await this.updateHistory({ historyId, step: 'after-maybe-ran-prepare', ranPrepareScript: true }); + await updateHistory({ historyId, step: 'after-maybe-ran-prepare', ranPrepareScript: true }); if (!opts) { - await this.updateHistory({ historyId, step: 'after-prepare-no-opts', finished: true }); + await updateHistory({ historyId, step: 'after-prepare-no-opts', finished: true }); return; } if (opts.message) { const prepareMessage = await this.sendMessage({ trigger, room, message: opts.message, data }); - await this.updateHistory({ + await updateHistory({ historyId, step: 'after-prepare-send-message', prepareSentMessage: prepareMessage, @@ -768,7 +473,7 @@ class RocketChatIntegrationHandler { } if (!opts.url || !opts.method) { - await this.updateHistory({ historyId, step: 'after-prepare-no-url_or_method', finished: true }); + await updateHistory({ historyId, step: 'after-prepare-no-url_or_method', finished: true }); return; } @@ -782,7 +487,7 @@ class RocketChatIntegrationHandler { opts.headers.Authorization = `Basic ${base64}`; } - await this.updateHistory({ + await updateHistory({ historyId, step: 'pre-http-call', url: opts.url, @@ -823,47 +528,42 @@ class RocketChatIntegrationHandler { } })(); - await this.updateHistory({ + await updateHistory({ historyId, step: 'after-http-call', httpError: null, httpResult: content, }); - if (this.hasScriptAndMethod(trigger, 'process_outgoing_response')) { - const sandbox = { + const responseContent = await this.wrapScriptEngineCall(() => + scriptEngine.processOutgoingResponse({ + integration: trigger, request: opts, - response: { - error: null, - status_code: res.status, // These values will be undefined to close issues #4175, #5762, and #5896 - content, - content_raw: content, - headers: Object.fromEntries(res.headers), - }, - }; - - const scriptResult = await this.executeScript(trigger, 'process_outgoing_response', sandbox, historyId); - - if (scriptResult && scriptResult.content) { - const resultMessage = await this.sendMessage({ - trigger, - room, - message: scriptResult.content, - data, - }); - await this.updateHistory({ - historyId, - step: 'after-process-send-message', - processSentMessage: resultMessage, - finished: true, - }); - return; - } + response: res, + content, + historyId, + }), + ); + + if (responseContent) { + const resultMessage = await this.sendMessage({ + trigger, + room, + message: responseContent, + data, + }); + await updateHistory({ + historyId, + step: 'after-process-send-message', + processSentMessage: resultMessage, + finished: true, + }); + return; + } - if (scriptResult === false) { - await this.updateHistory({ historyId, step: 'after-process-false-result', finished: true }); - return; - } + if (responseContent === false) { + await updateHistory({ historyId, step: 'after-process-false-result', finished: true }); + return; } // if the result contained nothing or wasn't a successful statusCode @@ -875,14 +575,14 @@ class RocketChatIntegrationHandler { }); if (res.status === 410) { - await this.updateHistory({ historyId, step: 'after-process-http-status-410', error: true }); + await updateHistory({ historyId, step: 'after-process-http-status-410', error: true }); outgoingLogger.error(`Disabling the Integration "${trigger.name}" because the status code was 401 (Gone).`); await Integrations.updateOne({ _id: trigger._id }, { $set: { enabled: false } }); return; } if (res.status === 500) { - await this.updateHistory({ historyId, step: 'after-process-http-status-500', error: true }); + await updateHistory({ historyId, step: 'after-process-http-status-500', error: true }); outgoingLogger.error({ msg: `Error "500" for the Integration "${trigger.name}" to ${url}.`, content, @@ -893,7 +593,7 @@ class RocketChatIntegrationHandler { if (trigger.retryFailedCalls) { if (tries < trigger.retryCount && trigger.retryDelay) { - await this.updateHistory({ historyId, error: true, step: `going-to-retry-${tries + 1}` }); + await updateHistory({ historyId, error: true, step: `going-to-retry-${tries + 1}` }); let waitTime; @@ -912,7 +612,7 @@ class RocketChatIntegrationHandler { break; default: const er = new Error("The integration's retryDelay setting is invalid."); - await this.updateHistory({ + await updateHistory({ historyId, step: 'failed-and-retry-delay-is-invalid', error: true, @@ -926,10 +626,10 @@ class RocketChatIntegrationHandler { void this.executeTriggerUrl(url, trigger, { event, message, room, owner, user }, historyId, tries + 1); }, waitTime); } else { - await this.updateHistory({ historyId, step: 'too-many-retries', error: true }); + await updateHistory({ historyId, step: 'too-many-retries', error: true }); } } else { - await this.updateHistory({ + await updateHistory({ historyId, step: 'failed-and-not-configured-to-retry', error: true, @@ -943,7 +643,7 @@ class RocketChatIntegrationHandler { if (content && this.successResults.includes(res.status)) { if (data?.text || data?.attachments) { const resultMsg = await this.sendMessage({ trigger, room, message: data, data }); - await this.updateHistory({ + await updateHistory({ historyId, step: 'url-response-sent-message', resultMessage: resultMsg, @@ -954,7 +654,7 @@ class RocketChatIntegrationHandler { }) .catch(async (error) => { outgoingLogger.error(error); - await this.updateHistory({ + await updateHistory({ historyId, step: 'after-http-call', httpError: error, diff --git a/apps/meteor/app/integrations/server/lib/updateHistory.ts b/apps/meteor/app/integrations/server/lib/updateHistory.ts new file mode 100644 index 000000000000..9f7a3017108d --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/updateHistory.ts @@ -0,0 +1,96 @@ +import type { IIntegrationHistory, IIntegration, IMessage, AtLeast } from '@rocket.chat/core-typings'; +import { IntegrationHistory } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +import { omit } from '../../../../lib/utils/omit'; + +export const updateHistory = async ({ + historyId, + step, + integration, + event, + data, + triggerWord, + ranPrepareScript, + prepareSentMessage, + processSentMessage, + resultMessage, + finished, + url, + httpCallData, + httpError, + httpResult, + error, + errorStack, +}: { + historyId: IIntegrationHistory['_id']; + step: IIntegrationHistory['step']; + integration?: IIntegration; + event?: string; + triggerWord?: string; + ranPrepareScript?: boolean; + prepareSentMessage?: { channel: string; message: Partial }[]; + processSentMessage?: { channel: string; message: Partial }[]; + resultMessage?: { channel: string; message: Partial }[]; + finished?: boolean; + url?: string; + httpCallData?: Record; // ProcessedOutgoingRequest.data + httpError?: any; // null or whatever error type `fetch` may throw + httpResult?: string | null; + + error?: boolean; + errorStack?: any; // Error | Error['stack'] + + data?: Record; +}) => { + const { user: userData, room: roomData, ...fullData } = data || {}; + + const history: AtLeast = { + type: 'outgoing-webhook', + step, + + // Usually is only added on initial insert + ...(integration ? { integration } : {}), + // Usually is only added on initial insert + ...(event ? { event } : {}), + ...(fullData + ? { + data: { + ...fullData, + ...(userData ? { user: omit(userData, 'services') } : {}), + ...(roomData ? { room: roomData } : {}), + }, + } + : {}), + ...(triggerWord ? { triggerWord } : {}), + ...(typeof ranPrepareScript !== 'undefined' ? { ranPrepareScript } : {}), + ...(prepareSentMessage ? { prepareSentMessage } : {}), + ...(processSentMessage ? { processSentMessage } : {}), + ...(resultMessage ? { resultMessage } : {}), + ...(typeof finished !== 'undefined' ? { finished } : {}), + ...(url ? { url } : {}), + ...(typeof httpCallData !== 'undefined' ? { httpCallData } : {}), + ...(httpError ? { httpError } : {}), + ...(typeof httpResult !== 'undefined' ? { httpResult: JSON.stringify(httpResult, null, 2) } : {}), + ...(typeof error !== 'undefined' ? { error } : {}), + ...(typeof errorStack !== 'undefined' ? { errorStack } : {}), + }; + + if (historyId) { + await IntegrationHistory.updateOne({ _id: historyId }, { $set: history }); + return historyId; + } + + // Can't create a new history without there being an integration + if (!history.integration) { + throw new Error('error-invalid-integration'); + } + + history._createdAt = new Date(); + + const _id = Random.id(); + + await IntegrationHistory.insertOne({ _id, ...history } as IIntegrationHistory); + + return _id; +}; diff --git a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts index d9c2db78b62e..398f81161279 100644 --- a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts @@ -1,19 +1,18 @@ import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { pick } from '@rocket.chat/tools'; import { Babel } from 'meteor/babel-compiler'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import { parseCSV } from '../../../../lib/utils/parseCSV'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { outgoingEvents } from '../../lib/outgoingEvents'; +import { isScriptEngineFrozen } from './validateScriptEngine'; const scopedChannels = ['all_public_channels', 'all_private_groups', 'all_direct_messages']; const validChannelChars = ['@', '#']; -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - function _verifyRequiredFields(integration: INewOutgoingIntegration | IUpdateOutgoingIntegration): void { if ( !integration.event || @@ -152,6 +151,7 @@ export const validateOutgoingIntegration = async function ( const integrationData: IOutgoingIntegration = { ...integration, + scriptEngine: integration.scriptEngine ?? 'isolated-vm', type: 'webhook-outgoing', channel: channels, userId: user._id, @@ -171,7 +171,13 @@ export const validateOutgoingIntegration = async function ( delete integrationData.triggerWords; } - if (!FREEZE_INTEGRATION_SCRIPTS && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') { + // Only compile the script if it is enabled and using a sandbox that is not frozen + if ( + !isScriptEngineFrozen(integrationData.scriptEngine) && + integration.scriptEnabled === true && + integration.script && + integration.script.trim() !== '' + ) { try { const babelOptions = Object.assign(Babel.getDefaultOptions({ runtime: false }), { compact: true, @@ -183,7 +189,7 @@ export const validateOutgoingIntegration = async function ( integrationData.scriptError = undefined; } catch (e) { integrationData.scriptCompiled = undefined; - integrationData.scriptError = e instanceof Error ? _.pick(e, 'name', 'message', 'stack') : undefined; + integrationData.scriptError = e instanceof Error ? pick(e, 'name', 'message', 'stack') : undefined; } } diff --git a/apps/meteor/app/integrations/server/lib/validateScriptEngine.ts b/apps/meteor/app/integrations/server/lib/validateScriptEngine.ts new file mode 100644 index 000000000000..c20dc9c59427 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/validateScriptEngine.ts @@ -0,0 +1,26 @@ +import type { IntegrationScriptEngine } from '@rocket.chat/core-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; + +const FREEZE_INTEGRATION_SCRIPTS_VALUE = String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase(); +const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(FREEZE_INTEGRATION_SCRIPTS_VALUE); + +export const validateScriptEngine = (engine?: IntegrationScriptEngine) => { + if (FREEZE_INTEGRATION_SCRIPTS) { + throw new Error('integration-scripts-disabled'); + } + + const engineCode = engine === 'isolated-vm' ? 'ivm' : 'vm2'; + + if (engineCode === FREEZE_INTEGRATION_SCRIPTS_VALUE) { + if (engineCode === 'ivm') { + throw new Error('integration-scripts-isolated-vm-disabled'); + } + + throw new Error('integration-scripts-vm2-disabled'); + } + + return true; +}; + +export const isScriptEngineFrozen = (engine?: IntegrationScriptEngine) => + wrapExceptions(() => !validateScriptEngine(engine)).catch(() => true); diff --git a/apps/meteor/app/integrations/server/lib/vm2/buildSandbox.ts b/apps/meteor/app/integrations/server/lib/vm2/buildSandbox.ts new file mode 100644 index 000000000000..9ba74404cf26 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/vm2/buildSandbox.ts @@ -0,0 +1,88 @@ +import * as Models from '@rocket.chat/models'; +import moment from 'moment'; +import _ from 'underscore'; + +import * as s from '../../../../../lib/utils/stringUtils'; +import { deasyncPromise } from '../../../../../server/deasync/deasync'; +import { httpCall } from '../../../../../server/lib/http/call'; + +const forbiddenModelMethods: readonly (keyof typeof Models)[] = ['registerModel', 'getCollectionName']; + +type ModelName = Exclude; + +export type Vm2Sandbox = { + scriptTimeout: (reject: (reason?: any) => void) => ReturnType; + _: typeof _; + s: typeof s; + console: typeof console; + moment: typeof moment; + Promise: typeof Promise; + Store: { + set: IsIncoming extends true ? (key: string, value: any) => any : (key: string, value: any) => void; + get: (key: string) => any; + }; + HTTP: (method: string, url: string, options: Record) => unknown; +} & (IsIncoming extends true ? { Livechat: undefined } : never) & + Record; + +export const buildSandbox = ( + store: Record, + isIncoming?: IsIncoming, +): { + store: Record; + sandbox: Vm2Sandbox; +} => { + const httpAsync = async (method: string, url: string, options: Record) => { + try { + return { + result: await httpCall(method, url, options), + }; + } catch (error) { + return { error }; + } + }; + + const sandbox = { + scriptTimeout(reject: (reason?: any) => void) { + return setTimeout(() => reject('timed out'), 3000); + }, + _, + s, + console, + moment, + Promise, + // There's a small difference between the sandbox that is sent to incoming and to outgoing scripts + // Technically we could unify this but since we're deprecating vm2 anyway I'm keeping this old behavior here until the feature is removed completely + ...(isIncoming + ? { + Livechat: undefined, + Store: { + set: (key: string, val: any): any => { + store[key] = val; + return val; + }, + get: (key: string) => store[key], + }, + } + : { + Store: { + set: (key: string, val: any): void => { + store[key] = val; + }, + get: (key: string) => store[key], + }, + }), + HTTP: (method: string, url: string, options: Record) => { + // TODO: deprecate, track and alert + return deasyncPromise(httpAsync(method, url, options)); + }, + } as Vm2Sandbox; + + (Object.keys(Models) as ModelName[]) + .filter((k) => !forbiddenModelMethods.includes(k)) + .forEach((k) => { + sandbox[k] = Models[k]; + }); + + return { store, sandbox }; +}; diff --git a/apps/meteor/app/integrations/server/lib/vm2/vm2.ts b/apps/meteor/app/integrations/server/lib/vm2/vm2.ts new file mode 100644 index 000000000000..5f7519d69346 --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/vm2/vm2.ts @@ -0,0 +1,111 @@ +import type { IIntegration } from '@rocket.chat/core-typings'; +import { VM, VMScript } from 'vm2'; + +import { IntegrationScriptEngine } from '../ScriptEngine'; +import type { IScriptClass } from '../definition'; +import { buildSandbox, type Vm2Sandbox } from './buildSandbox'; + +const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true', 'vm2'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); + +export class VM2ScriptEngine extends IntegrationScriptEngine { + protected isDisabled(): boolean { + return DISABLE_INTEGRATION_SCRIPTS; + } + + protected buildSandbox(store: Record = {}): { store: Record; sandbox: Vm2Sandbox } { + return buildSandbox(store, this.incoming); + } + + protected async runScriptMethod({ + integrationId, + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: IScriptClass; + method: keyof IScriptClass; + params: Record; + }): Promise { + const { sandbox } = this.buildSandbox(this.compiledScripts[integrationId].store); + + const vm = new VM({ + timeout: 3000, + sandbox: { + ...sandbox, + script, + method, + params, + ...(this.incoming && 'request' in params ? { request: params.request } : {}), + }, + }); + + return new Promise((resolve, reject) => { + process.nextTick(async () => { + try { + const scriptResult = await vm.run(` + new Promise((resolve, reject) => { + scriptTimeout(reject); + try { + resolve(script[method](params)) + } catch(e) { + reject(e); + } + }).catch((error) => { throw new Error(error); }); + `); + + resolve(scriptResult); + } catch (e) { + reject(e); + } + }); + }); + } + + protected async getIntegrationScript(integration: IIntegration): Promise> { + if (this.disabled) { + throw new Error('integration-scripts-disabled'); + } + + const compiledScript = this.compiledScripts[integration._id]; + if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { + return compiledScript.script; + } + + const script = integration.scriptCompiled; + const { store, sandbox } = this.buildSandbox(); + + try { + this.logger.info({ msg: 'Will evaluate script of Trigger', integration: integration.name }); + this.logger.debug(script); + + const vmScript = new VMScript(`${script}; Script;`, 'script.js'); + const vm = new VM({ + sandbox, + }); + + const ScriptClass = vm.run(vmScript); + + if (ScriptClass) { + this.compiledScripts[integration._id] = { + script: new ScriptClass(), + store, + _updatedAt: integration._updatedAt, + }; + + return this.compiledScripts[integration._id].script; + } + } catch (err) { + this.logger.error({ + msg: 'Error evaluating Script in Trigger', + integration: integration.name, + script, + err, + }); + throw new Error('error-evaluating-script'); + } + + this.logger.error({ msg: 'Class "Script" not in Trigger', integration: integration.name }); + throw new Error('class-script-not-found'); + } +} diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index bf84957ba8ea..45548a17a565 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -8,11 +8,10 @@ import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { validateScriptEngine, isScriptEngineFrozen } from '../../lib/validateScriptEngine'; const validChannelChars = ['@', '#']; -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -32,6 +31,7 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn alias: Match.Maybe(String), emoji: Match.Maybe(String), scriptEnabled: Boolean, + scriptEngine: Match.Maybe(String), overrideDestinationChannelEnabled: Match.Maybe(Boolean), script: Match.Maybe(String), avatar: Match.Maybe(String), @@ -76,8 +76,8 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn }); } - if (FREEZE_INTEGRATION_SCRIPTS && integration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); + if (integration.script?.trim()) { + validateScriptEngine(integration.scriptEngine ?? 'isolated-vm'); } const user = await Users.findOne({ username: integration.username }); @@ -90,6 +90,7 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn const integrationData: IIncomingIntegration = { ...integration, + scriptEngine: integration.scriptEngine ?? 'isolated-vm', type: 'webhook-incoming', channel: channels, overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false, @@ -99,7 +100,13 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn _createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }), }; - if (integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') { + // Only compile the script if it is enabled and using a sandbox that is not frozen + if ( + !isScriptEngineFrozen(integrationData.scriptEngine) && + integration.scriptEnabled === true && + integration.script && + integration.script.trim() !== '' + ) { try { let babelOptions = Babel.getDefaultOptions({ runtime: false }); babelOptions = _.extend(babelOptions, { compact: true, minified: true, comments: false }); diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index b865c72e0cca..5358e3233ce7 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -1,16 +1,16 @@ import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration } from '@rocket.chat/core-typings'; import { Integrations, Roles, Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Babel } from 'meteor/babel-compiler'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine'; const validChannelChars = ['@', '#']; -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -66,11 +66,20 @@ Meteor.methods({ }); } - if (FREEZE_INTEGRATION_SCRIPTS) { - if (currentIntegration.script?.trim() !== integration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); - } - } else { + const oldScriptEngine = currentIntegration.scriptEngine ?? 'vm2'; + const scriptEngine = integration.scriptEngine ?? oldScriptEngine; + if ( + integration.script?.trim() && + (scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim()) + ) { + wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => { + throw new Meteor.Error(e.message); + }); + } + + const isFrozen = isScriptEngineFrozen(scriptEngine); + + if (!isFrozen) { let scriptCompiled: string | undefined; let scriptError: Pick | undefined; @@ -165,11 +174,12 @@ Meteor.methods({ emoji: integration.emoji, alias: integration.alias, channel: channels, - ...(FREEZE_INTEGRATION_SCRIPTS + ...(isFrozen ? {} : { script: integration.script, scriptEnabled: integration.scriptEnabled, + scriptEngine, }), ...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && { overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, diff --git a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts index 9e5d29261b36..59879f99d475 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { validateOutgoingIntegration } from '../../lib/validateOutgoingIntegration'; +import { validateScriptEngine } from '../../lib/validateScriptEngine'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,8 +15,6 @@ declare module '@rocket.chat/ui-contexts' { } } -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - export const addOutgoingIntegration = async (userId: string, integration: INewOutgoingIntegration): Promise => { check( integration, @@ -29,6 +28,7 @@ export const addOutgoingIntegration = async (userId: string, integration: INewOu emoji: Match.Maybe(String), scriptEnabled: Boolean, script: Match.Maybe(String), + scriptEngine: Match.Maybe(String), urls: Match.Maybe([String]), event: Match.Maybe(String), triggerWords: Match.Maybe([String]), @@ -52,8 +52,8 @@ export const addOutgoingIntegration = async (userId: string, integration: INewOu throw new Meteor.Error('not_authorized'); } - if (FREEZE_INTEGRATION_SCRIPTS && integration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); + if (integration.script?.trim()) { + validateScriptEngine(integration.scriptEngine ?? 'isolated-vm'); } const integrationData = await validateOutgoingIntegration(integration, userId); diff --git a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts index 166badee823d..9e62561ebf9a 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts @@ -1,10 +1,12 @@ import type { IIntegration, INewOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings'; import { Integrations, Users } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { validateOutgoingIntegration } from '../../lib/validateOutgoingIntegration'; +import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -16,8 +18,6 @@ declare module '@rocket.chat/ui-contexts' { } } -const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase()); - Meteor.methods({ async updateOutgoingIntegration(integrationId, _integration) { if (!this.userId) { @@ -53,10 +53,19 @@ Meteor.methods({ throw new Meteor.Error('invalid_integration', '[methods] updateOutgoingIntegration -> integration not found'); } - if (FREEZE_INTEGRATION_SCRIPTS && integration.script?.trim() !== currentIntegration.script?.trim()) { - throw new Meteor.Error('integration-scripts-disabled'); + const oldScriptEngine = currentIntegration.scriptEngine ?? 'vm2'; + const scriptEngine = integration.scriptEngine ?? oldScriptEngine; + if ( + integration.script?.trim() && + (scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim()) + ) { + wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => { + throw new Meteor.Error(e.message); + }); } + const isFrozen = isScriptEngineFrozen(scriptEngine); + await Integrations.updateOne( { _id: integrationId }, { @@ -74,11 +83,12 @@ Meteor.methods({ userId: integration.userId, urls: integration.urls, token: integration.token, - ...(FREEZE_INTEGRATION_SCRIPTS + ...(isFrozen ? {} : { script: integration.script, scriptEnabled: integration.scriptEnabled, + scriptEngine, ...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }), }), triggerWords: integration.triggerWords, @@ -90,7 +100,7 @@ Meteor.methods({ _updatedAt: new Date(), _updatedBy: await Users.findOne({ _id: this.userId }, { projection: { username: 1 } }), }, - ...(FREEZE_INTEGRATION_SCRIPTS + ...(isFrozen ? {} : { $unset: { diff --git a/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js b/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js index 94bbd156b86c..ae4d4fa411b5 100644 --- a/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js +++ b/apps/meteor/client/views/admin/integrations/IncomingWebhookForm.js @@ -1,4 +1,4 @@ -import { Field, TextInput, Box, ToggleSwitch, Icon, TextAreaInput, FieldGroup, Margins } from '@rocket.chat/fuselage'; +import { Field, TextInput, Box, ToggleSwitch, Icon, TextAreaInput, FieldGroup, Margins, Select } from '@rocket.chat/fuselage'; import { useAbsoluteUrl, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo, useCallback } from 'react'; @@ -11,7 +11,8 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat const absoluteUrl = useAbsoluteUrl(); - const { enabled, channel, username, name, alias, avatar, emoji, scriptEnabled, script, overrideDestinationChannelEnabled } = formValues; + const { enabled, channel, username, name, alias, avatar, emoji, scriptEnabled, script, scriptEngine, overrideDestinationChannelEnabled } = + formValues; const { handleEnabled, @@ -24,6 +25,7 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat handleScriptEnabled, handleOverrideDestinationChannelEnabled, handleScript, + handleScriptEngine, } = formHandlers; const url = absoluteUrl(`hooks/${extraData._id}/${extraData.token}`); @@ -42,6 +44,14 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat url, }); + const scriptEngineOptions = useMemo( + () => [ + ['vm2', t('Script_Engine_vm2')], + ['isolated-vm', t('Script_Engine_isolated_vm')], + ], + [t], + ); + const hilightedExampleJson = useHighlightedCode('json', JSON.stringify(exampleData, null, 2)); return ( @@ -172,6 +182,18 @@ export default function IncomingWebhookForm({ formValues, formHandlers, extraDat ), [t, scriptEnabled, handleScriptEnabled], )} + {useMemo( + () => ( + + {t('Script_Engine')} + + + + {t('Script_Engine_Description')} + + ), + [scriptEngine, scriptEngineOptions, handleScriptEngine, t], + )} {useMemo( () => ( diff --git a/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js b/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js index cbe3c3e5377d..e785f63ca29d 100644 --- a/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js +++ b/apps/meteor/client/views/admin/integrations/edit/EditIncomingWebhook.js @@ -17,6 +17,7 @@ const getInitialValue = (data) => { avatar: data.avatar ?? '', emoji: data.emoji ?? '', scriptEnabled: data.scriptEnabled, + scriptEngine: data.scriptEngine ?? 'vm2', overrideDestinationChannelEnabled: data.overrideDestinationChannelEnabled, script: data.script, }; diff --git a/apps/meteor/client/views/admin/integrations/edit/EditOutgoingWebhook.js b/apps/meteor/client/views/admin/integrations/edit/EditOutgoingWebhook.js index 1734f32968c9..383b9209519d 100644 --- a/apps/meteor/client/views/admin/integrations/edit/EditOutgoingWebhook.js +++ b/apps/meteor/client/views/admin/integrations/edit/EditOutgoingWebhook.js @@ -24,6 +24,7 @@ const getInitialValue = (data) => { avatar: data.avatar ?? '', emoji: data.emoji ?? '', scriptEnabled: data.scriptEnabled ?? false, + scriptEngine: data.scriptEngine ?? 'vm2', script: data.script ?? '', retryFailedCalls: data.retryFailedCalls ?? true, retryCount: data.retryCount ?? 5, diff --git a/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js b/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js index 019dc6d0d730..7b4e0880e57f 100644 --- a/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js +++ b/apps/meteor/client/views/admin/integrations/new/NewIncomingWebhook.js @@ -15,6 +15,7 @@ const initialState = { avatar: '', emoji: '', scriptEnabled: false, + scriptEngine: 'isolated-vm', overrideDestinationChannelEnabled: false, script: '', }; diff --git a/apps/meteor/client/views/admin/integrations/new/NewOutgoingWebhook.js b/apps/meteor/client/views/admin/integrations/new/NewOutgoingWebhook.js index 818082f5f5de..153dc4c6eb7f 100644 --- a/apps/meteor/client/views/admin/integrations/new/NewOutgoingWebhook.js +++ b/apps/meteor/client/views/admin/integrations/new/NewOutgoingWebhook.js @@ -23,6 +23,7 @@ const defaultData = { avatar: '', emoji: '', scriptEnabled: false, + scriptEngine: 'isolated-vm', script: '', retryFailedCalls: true, retryCount: 6, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 9e68456a78c8..5dea47ff3a10 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -347,6 +347,7 @@ "imap": "^0.8.19", "ip-range-check": "^0.2.0", "is-svg": "^4.3.2", + "isolated-vm": "4.4.2", "jquery": "^3.6.0", "jschardet": "^3.0.0", "jsdom": "^16.7.0", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 197d38d603f0..e3bf5ef3194c 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2610,6 +2610,8 @@ "Integration_Incoming_WebHook": "Incoming WebHook Integration", "Integration_New": "New Integration", "integration-scripts-disabled": "Integration Scripts are Disabled", + "integration-scripts-isolated-vm-disabled": "The \"Secure Sandbox\" may not be used on new or modified scripts.", + "integration-scripts-vm2-disabled": "The \"Compatible Sandbox\" may not be used on new or modified scripts.", "Integration_Outgoing_WebHook": "Outgoing WebHook Integration", "Integration_Outgoing_WebHook_History": "Outgoing WebHook Integration History", "Integration_Outgoing_WebHook_History_Data_Passed_To_Trigger": "Data Passed to Integration", @@ -4520,6 +4522,10 @@ "Screen_Share": "Screen Share", "Script": "Script", "Script_Enabled": "Script Enabled", + "Script_Engine": "Script Sandbox", + "Script_Engine_Description": "Older scripts may require the compatible sandbox to run properly, but all new scripts should try to use the secure sandbox instead.", + "Script_Engine_vm2": "Compatible Sandbox (Deprecated)", + "Script_Engine_isolated_vm": "Secure Sandbox", "Search": "Search", "Searchable": "Searchable", "Search_Apps": "Search apps", diff --git a/packages/core-typings/src/IIntegration.ts b/packages/core-typings/src/IIntegration.ts index 6b99424264b2..cffff75767f4 100644 --- a/packages/core-typings/src/IIntegration.ts +++ b/packages/core-typings/src/IIntegration.ts @@ -1,6 +1,8 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IUser } from './IUser'; +export type IntegrationScriptEngine = 'vm2' | 'isolated-vm'; + export interface IIncomingIntegration extends IRocketChatRecord { type: 'webhook-incoming'; _createdBy: Pick | null; @@ -22,6 +24,8 @@ export interface IIncomingIntegration extends IRocketChatRecord { alias?: string; avatar?: string; emoji?: string; + + scriptEngine?: IntegrationScriptEngine; } export type OutgoingIntegrationEvent = @@ -65,6 +69,8 @@ export interface IOutgoingIntegration extends IRocketChatRecord { alias?: string; avatar?: string; emoji?: string; + + scriptEngine?: IntegrationScriptEngine; } export type IIntegration = IIncomingIntegration | IOutgoingIntegration; diff --git a/packages/core-typings/src/IIntegrationHistory.ts b/packages/core-typings/src/IIntegrationHistory.ts index 6297cd7d74a0..6594d611fb49 100644 --- a/packages/core-typings/src/IIntegrationHistory.ts +++ b/packages/core-typings/src/IIntegrationHistory.ts @@ -1,3 +1,4 @@ +import type { IMessage } from './IMessage'; import type { IRocketChatRecord } from './IRocketChatRecord'; export interface IIntegrationHistory extends IRocketChatRecord { @@ -17,10 +18,10 @@ export interface IIntegrationHistory extends IRocketChatRecord { finished: boolean; triggerWord?: string; - prepareSentMessage?: string; - processSentMessage?: string; + prepareSentMessage?: { channel: string; message: Partial }[]; + processSentMessage?: { channel: string; message: Partial }[]; url?: string; - httpCallData?: string; + httpCallData?: Record; httpError?: any; httpResult?: string; error?: any; diff --git a/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts b/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts index e9ef650656cd..249a12096729 100644 --- a/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts +++ b/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts @@ -1,4 +1,4 @@ -import type { OutgoingIntegrationEvent } from '@rocket.chat/core-typings'; +import type { OutgoingIntegrationEvent, IntegrationScriptEngine } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; const ajv = new Ajv(); @@ -16,6 +16,7 @@ export type IntegrationsCreateProps = alias?: string; avatar?: string; emoji?: string; + scriptEngine?: IntegrationScriptEngine; } | { type: 'webhook-outgoing'; @@ -44,6 +45,7 @@ export type IntegrationsCreateProps = alias?: string; avatar?: string; emoji?: string; + scriptEngine?: IntegrationScriptEngine; }; const integrationsCreateSchema = { @@ -96,6 +98,10 @@ const integrationsCreateSchema = { type: 'string', nullable: true, }, + scriptEngine: { + type: 'string', + nullable: true, + }, }, required: ['type', 'username', 'channel', 'scriptEnabled', 'name', 'enabled'], additionalProperties: false, @@ -196,6 +202,10 @@ const integrationsCreateSchema = { type: 'string', nullable: true, }, + scriptEngine: { + type: 'string', + nullable: true, + }, }, required: ['type', 'username', 'channel', 'event', 'scriptEnabled', 'name', 'enabled'], additionalProperties: false, diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 261823100d0a..b8bc90d9cb54 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,3 +1,4 @@ export * from './pick'; export * from './timezone'; export * from './stream'; +export * from './wrapExceptions'; diff --git a/packages/tools/src/wrapExceptions.ts b/packages/tools/src/wrapExceptions.ts new file mode 100644 index 000000000000..bd830a92bfeb --- /dev/null +++ b/packages/tools/src/wrapExceptions.ts @@ -0,0 +1,46 @@ +const isPromise = (value: unknown): value is Promise => !!value && value instanceof Promise; + +export function wrapExceptions( + getter: () => T, +): { + catch: (errorWrapper: (error: any) => T) => T; + suppress: (errorWrapper?: (error: any) => void) => T | undefined; +}; +export function wrapExceptions( + getter: () => Promise, +): { + catch: (errorWrapper: (error: any) => T | Awaited) => Promise; + suppress: (errorWrapper?: (error: any) => void) => Promise; +}; +export function wrapExceptions(getter: () => T) { + const doCatch = (errorWrapper: (error: any) => T | Awaited): T => { + try { + const value = getter(); + if (isPromise(value)) { + return value.catch(errorWrapper) as T; + } + + return value; + } catch (error) { + return errorWrapper(error); + } + }; + + const doSuppress = (errorWrapper?: (error: any) => void) => { + try { + const value = getter(); + if (isPromise(value)) { + return value.catch((error) => errorWrapper?.(error)); + } + + return value; + } catch (error) { + errorWrapper?.(error); + } + }; + + return { + catch: doCatch, + suppress: doSuppress, + }; +} diff --git a/yarn.lock b/yarn.lock index 7d7b23691683..145079c6eb7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8796,6 +8796,7 @@ __metadata: imap: ^0.8.19 ip-range-check: ^0.2.0 is-svg: ^4.3.2 + isolated-vm: 4.4.2 jest: ~29.6.1 jquery: ^3.6.0 jschardet: ^3.0.0 @@ -25272,6 +25273,15 @@ __metadata: languageName: node linkType: hard +"isolated-vm@npm:4.4.2": + version: 4.4.2 + resolution: "isolated-vm@npm:4.4.2" + dependencies: + node-gyp: latest + checksum: 86d12d96f90ceef74a3fc096439c71b0c115235ae3053d600eb8f7c678443d9ca3c8a2805dcd7f97463d11eb7d2e667868946b90e377a3e6d50fdd4085506fbc + languageName: node + linkType: hard + "isomorphic-unfetch@npm:^3.1.0": version: 3.1.0 resolution: "isomorphic-unfetch@npm:3.1.0" From 7061b67a3dd041c78a7f9f78dca89838800de93f Mon Sep 17 00:00:00 2001 From: Tiago Evangelista Pinto Date: Wed, 27 Sep 2023 02:28:31 -0300 Subject: [PATCH 4/6] chore: Changing some key translations - Setup Wizard (#30462) Co-authored-by: Guilherme Gazzo <5263975+ggazzo@users.noreply.github.com> --- .../rocketchat-i18n/i18n/en.i18n.json | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index e3bf5ef3194c..849f00c11428 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -5855,7 +5855,7 @@ "onboarding.component.form.action.confirm": "Confirm", "onboarding.component.form.termsAndConditions": "I agree with <1>Terms and Conditions and <3>Privacy Policy", "onboarding.component.emailCodeFallback": "Didn’t receive email? <1>Resend or <3>Change email", - "onboarding.page.form.title": "Let's <1>Launch Your Workspace", + "onboarding.page.form.title": "Let's launch your workspace", "onboarding.page.awaitingConfirmation.title": "Awaiting confirmation", "onboarding.page.awaitingConfirmation.subtitle": "We have sent you an email to {{emailAddress}} with a confirmation link. Please verify that the security code below matches the one in the email.", "onboarding.page.emailConfirmed.title": "Email Confirmed!", @@ -5882,10 +5882,8 @@ "onboarding.page.requestTrial.subtitle": "Try our best Enterprise Edition plan for 30 days for free", "onboarding.page.magicLinkEmail.title": "We emailed you a login link", "onboarding.page.magicLinkEmail.subtitle": "Click the link in the email we just sent you to sign in to your workspace. <1>The link will expire in 30 minutes.", - "onboarding.page.organizationInfoPage.title": "A few more details...", - "onboarding.page.organizationInfoPage.subtitle": "These will help us to personalize your workspace.", "onboarding.form.adminInfoForm.title": "Admin Info", - "onboarding.form.adminInfoForm.subtitle": "We need this to create an admin profile inside your workspace", + "onboarding.form.adminInfoForm.subtitle": "We need this information to create an admin profile for your workspace.", "onboarding.form.adminInfoForm.fields.fullName.label": "Full name", "onboarding.form.adminInfoForm.fields.fullName.placeholder": "First and last name", "onboarding.form.adminInfoForm.fields.username.label": "Username", @@ -5896,7 +5894,7 @@ "onboarding.form.adminInfoForm.fields.password.placeholder": "Create password", "onboarding.form.adminInfoForm.fields.keepPosted.label": "Keep me posted about Rocket.Chat updates", "onboarding.form.organizationInfoForm.title": "Organization Info", - "onboarding.form.organizationInfoForm.subtitle": "Please, bear with us. This info will help us personalize your workspace", + "onboarding.form.organizationInfoForm.subtitle": "We need to know who you are.", "onboarding.form.organizationInfoForm.fields.organizationName.label": "Organization name", "onboarding.form.organizationInfoForm.fields.organizationName.placeholder": "Organization name", "onboarding.form.organizationInfoForm.fields.organizationType.label": "Organization type", @@ -5907,17 +5905,16 @@ "onboarding.form.organizationInfoForm.fields.organizationSize.placeholder": "Select", "onboarding.form.organizationInfoForm.fields.country.label": "Country", "onboarding.form.organizationInfoForm.fields.country.placeholder": "Select", - "onboarding.form.registeredServerForm.title": "Register Your Server", + "onboarding.form.registeredServerForm.title": "Register your workspace", "onboarding.form.registeredServerForm.included.push": "Mobile push notifications", "onboarding.form.registeredServerForm.included.externalProviders": "Integration with external providers (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Access to marketplace apps", - "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Cloud account email", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "To register your server, we need to connect it to your cloud account. If you already have one - we will link it automatically. Otherwise, a new account will be created", - "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Please enter your Email", + "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Admin email", + "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Insert your email to continue", "onboarding.form.registeredServerForm.keepInformed": "Keep me informed about news and events", "onboarding.form.registeredServerForm.registerLater": "Register later", "onboarding.form.registeredServerForm.notConnectedToInternet": "The server is not connected to the internet, so you’ll have to do an offline registration for this workspace.", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "By registering I agree to receive relevant product and security updates", + "onboarding.form.registeredServerForm.registrationEngagement": "Registration allows automatic license updates, notifications of critical vulnerabilities and access to Rocket.Chat Cloud services. No sensitive workspace data is shared; statistics sent to Rocket.Chat is made visible to you within the administration area.", "onboarding.form.standaloneServerForm.title": "Standalone Server Confirmation", "onboarding.form.standaloneServerForm.servicesUnavailable": "Some of the services will be unavailable or will require manual setup", "onboarding.form.standaloneServerForm.publishOwnApp": "In order to send push notitications you need to compile and publish your own app to Google Play and App Store", @@ -6057,4 +6054,4 @@ "Filter_by_room": "Filter by room type", "Filter_by_visibility": "Filter by visibility", "Theme_Appearence": "Theme Appearence" -} \ No newline at end of file +} From 27c75f15f3cb1f49e8228a75de2332d71cc8fd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Wed, 27 Sep 2023 10:30:14 -0300 Subject: [PATCH 5/6] feat: New records page analytics tab (#30373) --- .changeset/fluffy-monkeys-sing.md | 5 +++ apps/meteor/client/views/admin/routes.tsx | 6 +-- .../meteor/client/views/admin/sidebarItems.ts | 4 +- .../views/admin/viewLogs/AnalyticsReports.tsx | 38 +++++++++++++++++++ .../views/admin/viewLogs/ViewLogsPage.tsx | 18 +++++++-- .../viewLogs/hooks/useAnalyticsObject.ts | 8 ++++ .../rocketchat-i18n/i18n/en.i18n.json | 4 ++ 7 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 .changeset/fluffy-monkeys-sing.md create mode 100644 apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx create mode 100644 apps/meteor/client/views/admin/viewLogs/hooks/useAnalyticsObject.ts diff --git a/.changeset/fluffy-monkeys-sing.md b/.changeset/fluffy-monkeys-sing.md new file mode 100644 index 000000000000..db93491b0ecd --- /dev/null +++ b/.changeset/fluffy-monkeys-sing.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Changed the name of the administration Logs page to "Records", implemented a tab layout in this page and added a new tab called "Analytic reports" that shows the most recent result of the statistics endpoint. diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index fa418b986cc1..a25bea5affaa 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -70,8 +70,8 @@ declare module '@rocket.chat/ui-contexts' { pattern: '/admin/registration/:page?'; }; 'admin-view-logs': { - pathname: '/admin/logs'; - pattern: '/admin/logs'; + pathname: '/admin/records'; + pattern: '/admin/records'; }; 'federation-dashboard': { pathname: '/admin/federation'; @@ -193,7 +193,7 @@ registerAdminRoute('/registration/:page?', { component: lazy(() => import('./cloud/CloudRoute')), }); -registerAdminRoute('/logs', { +registerAdminRoute('/records', { name: 'admin-view-logs', component: lazy(() => import('./viewLogs/ViewLogsRoute')), }); diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index c397e28e6db1..50a3284b5ed1 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -112,8 +112,8 @@ export const { permissionGranted: (): boolean => hasPermission('run-import'), }, { - href: '/admin/logs', - i18nLabel: 'Logs', + href: '/admin/records', + i18nLabel: 'Records', icon: 'post', permissionGranted: (): boolean => hasPermission('view-logs'), }, diff --git a/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx new file mode 100644 index 000000000000..7771298ceb73 --- /dev/null +++ b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx @@ -0,0 +1,38 @@ +import { Box, Icon, Skeleton } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import { useAnalyticsObject } from './hooks/useAnalyticsObject'; + +const AnalyticsReports = () => { + const t = useTranslation(); + + const { data, isLoading, isSuccess, isError } = useAnalyticsObject(); + + return ( + <> + + + + + + {t('How_and_why_we_collect_usage_data')} + + {t('Analytics_page_briefing')} + + + {isSuccess &&
{JSON.stringify(data, null, '\t')}
} + {isError && t('Something_went_wrong_try_again_later')} + {isLoading && ( + <> + + + + + )} +
+ + ); +}; + +export default AnalyticsReports; diff --git a/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx b/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx index a75c22da19b0..4463fec8f5bf 100644 --- a/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx +++ b/apps/meteor/client/views/admin/viewLogs/ViewLogsPage.tsx @@ -1,18 +1,30 @@ +import { Tabs } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useState } from 'react'; import Page from '../../../components/Page'; +import AnalyticsReports from './AnalyticsReports'; import ServerLogs from './ServerLogs'; const ViewLogsPage = (): ReactElement => { const t = useTranslation(); + const [tab, setTab] = useState('Logs'); + return ( - + - + + setTab('Logs')} selected={tab === 'Logs'}> + {t('Logs')} + + setTab('Analytics')} selected={tab === 'Analytics'}> + {t('Analytic_reports')} + + + {tab === 'Logs' ? : } ); diff --git a/apps/meteor/client/views/admin/viewLogs/hooks/useAnalyticsObject.ts b/apps/meteor/client/views/admin/viewLogs/hooks/useAnalyticsObject.ts new file mode 100644 index 000000000000..8aad0e605964 --- /dev/null +++ b/apps/meteor/client/views/admin/viewLogs/hooks/useAnalyticsObject.ts @@ -0,0 +1,8 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +export const useAnalyticsObject = () => { + const getAnalytics = useEndpoint('GET', '/v1/statistics'); + + return useQuery(['analytics'], () => getAnalytics({}), { staleTime: 10 * 60 * 1000 }); +}; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 849f00c11428..0df90c85bd8e 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -415,6 +415,7 @@ "Also_send_to_channel": "Also send to channel", "Always_open_in_new_window": "Always Open in New Window", "Always_show_thread_replies_in_main_channel": "Always show thread replies in main channel", + "Analytic_reports": "Analytic reports", "Analytics": "Analytics", "Analytics_Description": "See how users interact with your workspace.", "Analytics_features_enabled": "Features Enabled", @@ -423,6 +424,7 @@ "Analytics_features_users_Description": "Tracks custom events related to actions related to users (password reset times, profile picture change, etc).", "Analytics_Google": "Google Analytics", "Analytics_Google_id": "Tracking ID", + "Analytics_page_briefing": "Rocket.Chat collects anonymous usage data to identify how many instances are deployed and to improve the product for all users. We take your privacy seriously, so the usage data is encrypted and stored securely.", "Analyze_practical_usage": "Analyze practical usage statistics about users, messages and channels", "and": "and", "And_more": "And {{length}} more", @@ -2471,6 +2473,7 @@ "Hospitality_Businness": "Hospitality Business", "hours": "hours", "Hours": "Hours", + "How_and_why_we_collect_usage_data": "How and why we collect usage data", "How_friendly_was_the_chat_agent": "How friendly was the chat agent?", "How_knowledgeable_was_the_chat_agent": "How knowledgeable was the chat agent?", "How_long_to_wait_after_agent_goes_offline": "How Long to Wait After Agent Goes Offline", @@ -4165,6 +4168,7 @@ "Receive_Login_Detection_Emails_Description": "Receive an email each time a new login is detected on your account.", "Recent_Import_History": "Recent Import History", "Record": "Record", + "Records": "Records", "recording": "recording", "Redirect_URI": "Redirect URI", "Redirect_URL_does_not_match": "Redirect URL does not match", From 699f10dd5728f1a8a44251f58eafd66d1285e631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=87=E3=83=AF=E3=83=B3=E3=82=B7=E3=83=A5?= <61188295+Dnouv@users.noreply.github.com> Date: Thu, 28 Sep 2023 02:04:04 +0530 Subject: [PATCH 6/6] fix: RTL lang crashes Moderation Console (#30393) --- .../client/views/admin/moderation/helpers/DateRangePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx b/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx index f870dbc52776..326cc3382b4b 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx @@ -10,7 +10,7 @@ type DateRangePickerProps = Omit, 'onChange'> & { onChange(range: { start: string; end: string }): void; }; -const formatToDateInput = (date: Moment) => date.format('YYYY-MM-DD'); +const formatToDateInput = (date: Moment) => date.locale('en').format('YYYY-MM-DD'); const todayDate = formatToDateInput(moment());