diff --git a/app/api/server/api.js b/app/api/server/api.js index 8c5f77b7fd0..d62382af875 100644 --- a/app/api/server/api.js +++ b/app/api/server/api.js @@ -326,9 +326,9 @@ export class APIClass extends Restivus { }); logger.debug(`${ this.request.method.toUpperCase() }: ${ this.request.url }`); - const requestIp = getRequestIP(this.request); + this.requestIp = getRequestIP(this.request); const objectForRateLimitMatch = { - IPAddr: requestIp, + IPAddr: this.requestIp, route: `${ this.request.route }${ this.request.method.toLowerCase() }`, }; let result; @@ -338,7 +338,7 @@ export class APIClass extends Restivus { close() {}, token: this.token, httpHeaders: this.request.headers, - clientAddress: requestIp, + clientAddress: this.requestIp, }; try { diff --git a/app/api/server/settings.js b/app/api/server/settings.js index f4a7413a8fb..9f775d2b73c 100644 --- a/app/api/server/settings.js +++ b/app/api/server/settings.js @@ -11,5 +11,11 @@ settings.addGroup('General', function() { this.add('API_Shield_user_require_auth', false, { type: 'boolean', public: false, enableQuery: { _id: 'API_Enable_Shields', value: true } }); this.add('API_Enable_CORS', false, { type: 'boolean', public: false }); this.add('API_CORS_Origin', '*', { type: 'string', public: false, enableQuery: { _id: 'API_Enable_CORS', value: true } }); + + this.add('API_Use_REST_For_DDP_Calls', false, { + type: 'boolean', + public: true, + alert: 'API_Use_REST_For_DDP_Calls_Alert', + }); }); }); diff --git a/app/api/server/v1/misc.js b/app/api/server/v1/misc.js index 4caefb48922..b975ab1201b 100644 --- a/app/api/server/v1/misc.js +++ b/app/api/server/v1/misc.js @@ -1,6 +1,10 @@ +import crypto from 'crypto'; + import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import { EJSON } from 'meteor/ejson'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import s from 'underscore.string'; import { hasRole, hasPermission } from '../../../authorization/server'; @@ -216,3 +220,54 @@ API.v1.addRoute('stdout.queue', { authRequired: true }, { return API.v1.success({ queue: StdOut.queue }); }, }); + +const mountResult = ({ id, error, result }) => ({ + message: EJSON.stringify({ + msg: 'result', + id, + error, + result, + }), +}); + +const methodCall = () => ({ + post() { + check(this.bodyParams, { + message: String, + }); + + const { method, params, id } = EJSON.parse(this.bodyParams.message); + + const connectionId = this.token || crypto.createHash('md5').update(this.requestIp + this.request.headers['user-agent']).digest('hex'); + + const rateLimiterInput = { + userId: this.userId, + clientAddress: this.requestIp, + type: 'method', + name: method, + connectionId, + }; + + try { + DDPRateLimiter._increment(rateLimiterInput); + const rateLimitResult = DDPRateLimiter._check(rateLimiterInput); + if (!rateLimitResult.allowed) { + throw new Meteor.Error( + 'too-many-requests', + DDPRateLimiter.getErrorMessage(rateLimitResult), + { timeToReset: rateLimitResult.timeToReset }, + ); + } + + const result = Meteor.call(method, ...params); + return API.v1.success(mountResult({ id, result })); + } catch (error) { + return API.v1.success(mountResult({ id, error })); + } + }, +}); + +// had to create two different endpoints for authenticated and non-authenticated calls +// because restivus does not provide 'this.userId' if 'authRequired: false' +API.v1.addRoute('method.call/:method', { authRequired: true, rateLimiterOptions: false }, methodCall()); +API.v1.addRoute('method.callAnon/:method', { authRequired: false, rateLimiterOptions: false }, methodCall()); diff --git a/app/livechat/client/views/app/tabbar/visitorInfo.js b/app/livechat/client/views/app/tabbar/visitorInfo.js index 3ebc6cc66a4..24de8917bce 100644 --- a/app/livechat/client/views/app/tabbar/visitorInfo.js +++ b/app/livechat/client/views/app/tabbar/visitorInfo.js @@ -344,8 +344,8 @@ Template.visitorInfo.onCreated(function() { }; if (rid) { - loadRoomData(rid); RoomManager.roomStream.on(rid, this.updateRoom); + loadRoomData(rid); } this.autorun(async () => { diff --git a/app/ui-cached-collection/client/models/CachedCollection.js b/app/ui-cached-collection/client/models/CachedCollection.js index f6c21c91049..59929c54ce0 100644 --- a/app/ui-cached-collection/client/models/CachedCollection.js +++ b/app/ui-cached-collection/client/models/CachedCollection.js @@ -11,7 +11,7 @@ import EventEmitter from 'wolfy87-eventemitter'; import { callbacks } from '../../../callbacks'; import Notifications from '../../../notifications/client/lib/Notifications'; import { getConfig } from '../../../ui-utils/client/config'; - +import { callMethod } from '../../../ui-utils/client/lib/callMethod'; const fromEntries = Object.fromEntries || function fromEntries(iterable) { return [...iterable].reduce((obj, { 0: key, 1: val }) => Object.assign(obj, { [key]: val }), {}); @@ -26,8 +26,6 @@ const wrap = (fn) => (...args) => new Promise((resolve, reject) => { }); }); -const call = wrap(Meteor.call); - const localforageGetItem = wrap(localforage.getItem); class CachedCollectionManagerClass extends EventEmitter { @@ -219,7 +217,7 @@ export class CachedCollection extends EventEmitter { async loadFromServer() { const startTime = new Date(); const lastTime = this.updatedAt; - const data = await call(this.methodName); + const data = await callMethod(this.methodName); this.log(`${ data.length } records loaded from server`); data.forEach((record) => { callbacks.run(`cachedCollection-loadFromServer-${ this.name }`, record, 'changed'); @@ -316,7 +314,7 @@ export class CachedCollection extends EventEmitter { this.log(`syncing from ${ this.updatedAt }`); - const data = await call(this.syncMethodName, this.updatedAt); + const data = await callMethod(this.syncMethodName, this.updatedAt); let changes = []; if (data.update && data.update.length > 0) { diff --git a/app/ui-master/server/inject.js b/app/ui-master/server/inject.js index 8b0e6b4c45f..62b08f40584 100644 --- a/app/ui-master/server/inject.js +++ b/app/ui-master/server/inject.js @@ -51,6 +51,13 @@ Meteor.startup(() => { `); } + settings.get('API_Use_REST_For_DDP_Calls', (key, value) => { + if (!value) { + return injectIntoHead(key, ''); + } + injectIntoHead(key, ''); + }); + settings.get('Assets_SvgFavicon_Enable', (key, value) => { const standardFavicons = ` diff --git a/app/ui-utils/client/lib/RoomManager.js b/app/ui-utils/client/lib/RoomManager.js index 0a9460c01b2..e6dfae8be3c 100644 --- a/app/ui-utils/client/lib/RoomManager.js +++ b/app/ui-utils/client/lib/RoomManager.js @@ -15,7 +15,7 @@ import { Notifications } from '../../../notifications'; import { CachedChatRoom, ChatMessage, ChatSubscription, CachedChatSubscription } from '../../../models'; import { CachedCollectionManager } from '../../../ui-cached-collection'; import { getConfig } from '../config'; -import { ROOM_DATA_STREAM_OBSERVER } from '../../../utils/stream/constants'; +import { ROOM_DATA_STREAM } from '../../../utils/stream/constants'; import { call } from '..'; @@ -45,7 +45,7 @@ const onDeleteMessageBulkStream = ({ rid, ts, excludePinned, ignoreDiscussion, u export const RoomManager = new function() { const openedRooms = {}; const msgStream = new Meteor.Streamer('room-messages'); - const roomStream = new Meteor.Streamer(ROOM_DATA_STREAM_OBSERVER); + const roomStream = new Meteor.Streamer(ROOM_DATA_STREAM); const onlineUsers = new ReactiveVar({}); const Dep = new Tracker.Dependency(); const Cls = class { diff --git a/app/utils/stream/constants.js b/app/utils/stream/constants.js index 5ae53cc865b..9f8587b17de 100644 --- a/app/utils/stream/constants.js +++ b/app/utils/stream/constants.js @@ -1 +1 @@ -export const ROOM_DATA_STREAM_OBSERVER = 'room-data-observer'; +export const ROOM_DATA_STREAM = 'room-data'; diff --git a/client/lib/meteorCallWrapper.ts b/client/lib/meteorCallWrapper.ts new file mode 100644 index 00000000000..9feea5cfbb5 --- /dev/null +++ b/client/lib/meteorCallWrapper.ts @@ -0,0 +1,65 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { DDPCommon } from 'meteor/ddp-common'; + +import { APIClient } from '../../app/utils/client'; + +const bypassMethods: string[] = [ + 'setUserStatus', +]; + +function shouldBypass({ method, params }: Meteor.IDDPMessage): boolean { + if (method === 'login' && params[0]?.resume) { + return true; + } + + if (method.startsWith('UserPresence:') || bypassMethods.includes(method)) { + return true; + } + + if (method.startsWith('stream-')) { + return true; + } + + return false; +} + +function wrapMeteorDDPCalls(): void { + const { _send } = Meteor.connection; + + Meteor.connection._send = function _DDPSendOverREST(message): void { + if (message.msg !== 'method' || shouldBypass(message)) { + return _send.call(Meteor.connection, message); + } + + const endpoint = Tracker.nonreactive(() => (!Meteor.userId() ? 'method.callAnon' : 'method.call')); + + const restParams = { + message: DDPCommon.stringifyDDP(message), + }; + + const processResult = (_message: any): void => { + Meteor.connection._livedata_data({ + msg: 'updated', + methods: [message.id], + }); + Meteor.connection.onMessage(_message); + }; + + APIClient.v1.post(`${ endpoint }/${ encodeURIComponent(message.method) }`, restParams) + .then(({ message: _message }) => { + processResult(_message); + if (message.method === 'login') { + const parsedMessage = DDPCommon.parseDDP(_message); + if (parsedMessage.result?.token) { + Meteor.loginWithToken(parsedMessage.result.token); + } + } + }) + .catch((error) => { + console.error(error); + }); + }; +} + +window.USE_REST_FOR_DDP_CALLS && wrapMeteorDDPCalls(); diff --git a/client/main.js b/client/main.js index 33ce4ec3a11..bf3471094d5 100644 --- a/client/main.js +++ b/client/main.js @@ -1,6 +1,7 @@ import '@rocket.chat/fuselage-polyfills'; import 'url-polyfill'; +import './lib/meteorCallWrapper'; import './importsCss'; import './importPackages'; import '../imports/startup/client'; diff --git a/client/meteor.d.ts b/client/meteor.d.ts new file mode 100644 index 00000000000..023a7d17e68 --- /dev/null +++ b/client/meteor.d.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { EJSON } from 'meteor/ejson'; + +declare module 'meteor/meteor' { + namespace Meteor { + interface IDDPMessage { + msg: 'method'; + method: string; + params: EJSON[]; + id: string; + } + + interface IDDPUpdatedMessage { + msg: 'updated'; + methods: string[]; + } + + interface IMeteorConnection { + _send(message: IDDPMessage): void; + + _livedata_data(message: IDDPUpdatedMessage): void; + + onMessage(message: string): void; + } + + const connection: IMeteorConnection; + } +} diff --git a/client/window.d.ts b/client/window.d.ts new file mode 100644 index 00000000000..fec2a52813a --- /dev/null +++ b/client/window.d.ts @@ -0,0 +1,8 @@ +export {}; + +declare global { + // eslint-disable-next-line @typescript-eslint/interface-name-prefix + interface Window { + USE_REST_FOR_DDP_CALLS?: boolean; + } +} diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 04aa250198e..bef4385c60a 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -359,6 +359,8 @@ "API_Tokenpass_URL_Description": "Example: https://domain.com (excluding trailing slash)", "API_Upper_Count_Limit": "Max Record Amount", "API_Upper_Count_Limit_Description": "What is the maximum number of records the REST API should return (when not unlimited)?", + "API_Use_REST_For_DDP_Calls": "Use REST instead of websocket for Meteor calls", + "API_Use_REST_For_DDP_Calls_Alert": "This is an experimental and temporary feature. It forces web client and mobile app to use REST requests instead of using websockets for Meteor method calls.", "API_User_Limit": "User Limit for Adding All Users to Channel", "API_Wordpress_URL": "WordPress URL", "Apiai_Key": "Api.ai Key", diff --git a/server/main.d.ts b/server/main.d.ts index f248e333a28..16772014e5f 100644 --- a/server/main.d.ts +++ b/server/main.d.ts @@ -1,3 +1,5 @@ +import { EJSON } from 'meteor/ejson'; + /* eslint-disable @typescript-eslint/interface-name-prefix */ declare module 'meteor/random' { namespace Random { @@ -33,6 +35,13 @@ declare module 'meteor/meteor' { } } +declare module 'meteor/ddp-common' { + namespace DDPCommon { + function stringifyDDP(msg: EJSON): string; + function parseDDP(msg: string): EJSON; + } +} + declare module 'meteor/rocketchat:tap-i18n' { namespace TAPi18n { function __(s: string, options: { lng: string }): string; diff --git a/server/stream/rooms/index.js b/server/stream/rooms/index.js index 5b33c403111..7ea683f290a 100644 --- a/server/stream/rooms/index.js +++ b/server/stream/rooms/index.js @@ -1,11 +1,9 @@ import { Meteor } from 'meteor/meteor'; import { roomTypes } from '../../../app/utils'; -import { ROOM_DATA_STREAM_OBSERVER } from '../../../app/utils/stream/constants'; +import { ROOM_DATA_STREAM } from '../../../app/utils/stream/constants'; -export const roomDataStream = new Meteor.Streamer(ROOM_DATA_STREAM_OBSERVER); - -const isEmitAllowed = (t) => roomTypes.getConfig(t).isEmitAllowed(); +export const roomDataStream = new Meteor.Streamer(ROOM_DATA_STREAM); roomDataStream.allowWrite('none'); @@ -16,11 +14,7 @@ roomDataStream.allowRead(function(rid) { return false; } - if (isEmitAllowed(room.t) === false) { - return false; - } - - return true; + return roomTypes.getConfig(room.t).isEmitAllowed(); } catch (error) { return false; } @@ -31,7 +25,7 @@ export function emitRoomDataEvent(id, data) { return; } - if (isEmitAllowed(data.t) === false) { + if (!roomTypes.getConfig(data.t).isEmitAllowed()) { return; }