import type { IIncomingIntegration, IIntegration, IOutgoingIntegration, IUser, RequiredField } from '@rocket.chat/core-typings'; import { Integrations, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { isIntegrationsHooksAddSchema, isIntegrationsHooksRemoveSchema } from '@rocket.chat/rest-typings'; import type express from 'express'; import type { Context, Next } from 'hono'; import { Meteor } from 'meteor/meteor'; import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit'; import { WebApp } from 'meteor/webapp'; import _ from 'underscore'; import { isPlainObject } from '../../../../lib/utils/isPlainObject'; import { APIClass } from '../../../api/server/ApiClass'; import type { RateLimiterOptions } from '../../../api/server/api'; import { API, defaultRateLimiterOptions } from '../../../api/server/api'; import type { FailureResult, GenericRouteExecutionContext, SuccessResult, UnavailableResult } from '../../../api/server/definition'; import type { WebhookResponseItem } from '../../../lib/server/functions/processWebhookMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { settings } from '../../../settings/server'; import { IsolatedVMScriptEngine } from '../lib/isolated-vm/isolated-vm'; import { incomingLogger } from '../logger'; import { addOutgoingIntegration } from '../methods/outgoing/addOutgoingIntegration'; import { deleteOutgoingIntegration } from '../methods/outgoing/deleteOutgoingIntegration'; const ivmEngine = new IsolatedVMScriptEngine(true); // eslint-disable-next-line no-unused-vars function getEngine(_integration: IIntegration): IsolatedVMScriptEngine { return ivmEngine; } type IntegrationOptions = { event: string; name: string; target_url: string; data?: { channel_name?: string; trigger_words?: string[]; username?: string; }; }; type IntegrationThis = GenericRouteExecutionContext & { request: Request & { integration: IIncomingIntegration; }; user: IUser & { username: RequiredField }; }; async function createIntegration(options: IntegrationOptions, user: IUser): Promise { incomingLogger.info({ msg: 'Add integration', integration: options.name }); incomingLogger.debug({ options }); switch (options.event) { case 'newMessageOnChannel': if (options.data == null) { options.data = {}; } if (options.data.channel_name != null && options.data.channel_name.indexOf('#') === -1) { options.data.channel_name = `#${options.data.channel_name}`; } return addOutgoingIntegration(user._id, { username: 'rocket.cat', urls: [options.target_url], name: options.name, channel: options.data.channel_name, triggerWords: options.data.trigger_words, type: 'webhook-outgoing', event: 'sendMessage', token: Random.id(24), scriptEnabled: false, script: '', enabled: true, _id: Random.id(), _updatedAt: new Date(), }); case 'newMessageToUser': if (options.data?.username?.indexOf('@') === -1) { options.data.username = `@${options.data.username}`; } if (!options.data?.username) { throw new Error('username-required'); } return addOutgoingIntegration(user._id, { username: 'rocket.cat', urls: [options.target_url], name: options.name, channel: options.data.username, triggerWords: options.data.trigger_words, _id: '', type: 'webhook-outgoing', token: '', scriptEnabled: false, script: '', enabled: false, _updatedAt: new Date(), event: 'sendMessage', }); } } async function removeIntegration(options: { target_url: string }, user: IUser): Promise | FailureResult> { incomingLogger.info('Remove integration'); incomingLogger.debug({ options }); const integrationToRemove = await Integrations.findOneByUrl(options.target_url); if (!integrationToRemove) { return API.v1.failure('integration-not-found'); } await deleteOutgoingIntegration(integrationToRemove._id, user._id); return API.v1.success(); } async function executeIntegrationRest( this: IntegrationThis, ): Promise< | SuccessResult | { responses: WebhookResponseItem[] } | undefined | void> | FailureResult | FailureResult<{ responses: WebhookResponseItem[] }> | UnavailableResult > { incomingLogger.info({ msg: 'Post integration:', integration: this.request.integration.name }); incomingLogger.debug({ urlParams: this.urlParams, bodyParams: this.bodyParams }); if (this.request.integration.enabled !== true) { return API.v1.unavailable('Service Unavailable'); } const defaultValues = { channel: this.request.integration.channel, alias: this.request.integration.alias || '', avatar: this.request.integration.avatar || '', emoji: this.request.integration.emoji || '', }; const scriptEngine = getEngine(this.request.integration); let bodyParams = isPlainObject(this.bodyParams) ? this.bodyParams : {}; const separateResponse = bodyParams.separateResponse === true; let scriptResponse: Record | undefined; if (scriptEngine.integrationHasValidScript(this.request.integration) && this.request.body) { const buffers = []; const reader = this.request.body.getReader(); // eslint-disable-next-line no-await-in-loop for (let result = await reader.read(); !result.done; result = await reader.read()) { buffers.push(result.value); } const contentRaw = Buffer.concat(buffers).toString('utf8'); const protocol = `${this.request.headers.get('x-forwarded-proto')}:` || 'http:'; const url = new URL(this.request.url, `${protocol}//${this.request.headers.get('host')}`); const query = isPlainObject(this.queryParams) ? this.queryParams : {}; const request = { url: { query, hash: url.hash, search: url.search, pathname: url.pathname, path: this.request.url, }, url_raw: this.request.url, url_params: this.urlParams, content: bodyParams, content_raw: contentRaw, headers: Object.fromEntries(this.request.headers.entries()), body: bodyParams, user: { _id: this.user._id, name: this.user.name || '', username: this.user.username, }, }; const result = await scriptEngine.processIncomingRequest({ integration: this.request.integration, request, }); try { if (!result) { incomingLogger.debug({ msg: 'Process Incoming Request result of Trigger has no data', integration: this.request.integration.name, }); return API.v1.success(); } if (result?.error) { return API.v1.failure(result.error); } bodyParams = result?.content; if (!('separateResponse' in bodyParams)) { bodyParams.separateResponse = separateResponse; } scriptResponse = result.response; if (result.user) { this.user = result.user; } incomingLogger.debug({ msg: 'Process Incoming Request result of Trigger', integration: this.request.integration.name, result: bodyParams, }); } catch (err) { incomingLogger.error({ msg: 'Error running Script in Trigger', integration: this.request.integration.name, script: this.request.integration.scriptCompiled, err, }); return API.v1.failure('error-running-script'); } } if (!bodyParams || (_.isEmpty(bodyParams) && !this.request.integration.scriptEnabled)) { return API.v1.success(); } if ((bodyParams.channel || bodyParams.roomId) && !this.request.integration.overrideDestinationChannelEnabled) { return API.v1.failure('overriding destination channel is disabled for this integration'); } bodyParams.bot = { i: this.request.integration._id }; try { const messageResponse = await processWebhookMessage(bodyParams, this.user, defaultValues); if (_.isEmpty(messageResponse)) { return API.v1.failure('unknown-error'); } if (scriptResponse) { incomingLogger.debug({ msg: 'response', response: scriptResponse }); return API.v1.success(scriptResponse); } if (bodyParams.separateResponse) { const allFailed = messageResponse.every((response) => 'error' in response && response.error); if (allFailed) { return API.v1.failure({ responses: messageResponse }); } return API.v1.success({ responses: messageResponse }); } return API.v1.success(); } catch ({ error, message }: any) { return API.v1.failure(error || message); } } type IntegrationSampleBody = { token: string; channel_id: string; channel_name: string; timestamp: Date; user_id: string; user_name: string; text: string; trigger_word: string; }; function integrationSampleRest(): { statusCode: number; body: IntegrationSampleBody[] } { incomingLogger.info('Sample Integration'); return { statusCode: 200, body: [ { token: Random.id(24), channel_id: Random.id(), channel_name: 'general', timestamp: new Date(), user_id: Random.id(), user_name: 'rocket.cat', text: 'Sample text 1', trigger_word: 'Sample', }, { token: Random.id(24), channel_id: Random.id(), channel_name: 'general', timestamp: new Date(), user_id: Random.id(), user_name: 'rocket.cat', text: 'Sample text 2', trigger_word: 'Sample', }, { token: Random.id(24), channel_id: Random.id(), channel_name: 'general', timestamp: new Date(), user_id: Random.id(), user_name: 'rocket.cat', text: 'Sample text 3', trigger_word: 'Sample', }, ], }; } function integrationInfoRest(): { statusCode: number; body: { success: boolean } } { incomingLogger.info('Info integration'); return { statusCode: 200, body: { success: true, }, }; } class WebHookAPI extends APIClass<'/hooks'> { override async authenticatedRoute(routeContext: IntegrationThis): Promise { const { integrationId, token } = routeContext.urlParams; const integration = await Integrations.findOneByIdAndToken(integrationId, decodeURIComponent(token)); if (!integration) { incomingLogger.info({ msg: 'Invalid integration id or token', integrationId, token }); throw new Error('Invalid integration id or token provided.'); } routeContext.request.integration = integration; return Users.findOneById(routeContext.request.integration.userId); } override shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean { const { rateLimiterOptions } = options; return ( (typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS) ); } override async shouldVerifyRateLimit(): Promise { return ( settings.get('API_Enable_Rate_Limiter') === true && (process.env.NODE_ENV !== 'development' || settings.get('API_Enable_Rate_Limiter_Dev') === true) ); } override async enforceRateLimit( objectForRateLimitMatch: RateLimiterOptionsToCheck, request: Request, response: Response, userId: string, ): Promise { const { method, url } = request; const route = url.replace(`/${this.apiPath}`, ''); const nameRoute = this.getFullRouteName(route, method.toLowerCase()); if (!this.getRateLimiter(nameRoute)) { this.addRateLimiterRuleForRoutes({ routes: [route], rateLimiterOptions: defaultRateLimiterOptions, endpoints: { post: 'executeIntegrationRest', get: 'executeIntegrationRest', }, }); } const integrationForRateLimitMatch = objectForRateLimitMatch; integrationForRateLimitMatch.route = nameRoute; await super.enforceRateLimit(integrationForRateLimitMatch, request, response, userId); } } const Api = new WebHookAPI({ enableCors: true, apiPath: 'hooks/', useDefaultAuth: false, prettyJson: process.env.NODE_ENV === 'development', }); const middleware = async (c: Context, next: Next): Promise => { const { req } = c; if (req.raw.headers.get('content-type') !== 'application/x-www-form-urlencoded') { return next(); } try { const content = await req.raw.clone().text(); const body = Object.fromEntries(new URLSearchParams(content)); if (!body || typeof body !== 'object' || Object.keys(body).length !== 1) { return next(); } if (body.payload) { // need to compose the full payload in this weird way because body-parser thought it was a form c.set('bodyParams-override', JSON.parse(body.payload)); return next(); } incomingLogger.debug({ msg: 'Body received as application/x-www-form-urlencoded without the "payload" key, parsed as string', content, }); c.set('bodyParams-override', JSON.parse(content)); } catch (e: any) { c.body(JSON.stringify({ success: false, error: e.message }), 400); } return next(); }; Api.router.use(middleware); Api.addRoute( ':integrationId/:userId/:token', { authRequired: true }, { post: executeIntegrationRest, get: executeIntegrationRest, }, ); Api.addRoute( ':integrationId/:token', { authRequired: true }, { post: executeIntegrationRest, get: executeIntegrationRest, }, ); Api.addRoute( 'sample/:integrationId/:userId/:token', { authRequired: true }, { get: integrationSampleRest, }, ); Api.addRoute( 'sample/:integrationId/:token', { authRequired: true }, { get: integrationSampleRest, }, ); Api.addRoute( 'info/:integrationId/:userId/:token', { authRequired: true }, { get: integrationInfoRest, }, ); Api.addRoute( 'info/:integrationId/:token', { authRequired: true }, { get: integrationInfoRest, }, ); Api.addRoute( 'add/:integrationId/:userId/:token', { authRequired: true, validateParams: isIntegrationsHooksAddSchema }, { async post() { const result = await createIntegration(this.bodyParams, this.user); return API.v1.success(result || {}); }, }, ); Api.addRoute( 'add/:integrationId/:token', { authRequired: true, validateParams: isIntegrationsHooksAddSchema }, { async post() { const result = await createIntegration(this.bodyParams, this.user); return API.v1.success(result || {}); }, }, ); Api.addRoute( 'remove/:integrationId/:userId/:token', { authRequired: true, validateParams: isIntegrationsHooksRemoveSchema }, { async post() { const result = await removeIntegration(this.bodyParams, this.user); return API.v1.success(result || {}); }, }, ); Api.addRoute( 'remove/:integrationId/:token', { authRequired: true, validateParams: isIntegrationsHooksRemoveSchema }, { async post() { const result = await removeIntegration(this.bodyParams, this.user); return API.v1.success(result || {}); }, }, ); Meteor.startup(() => { (WebApp.rawConnectHandlers as unknown as ReturnType).use(Api.router.router); });