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;
}