[NEW] Federation (#12370)

pull/13623/head
Alan Sikora 7 years ago committed by Diego Sampaio
parent dc51cdc4cd
commit a080cd9b6b
  1. 1
      .meteor/packages
  2. 1
      .meteor/versions
  3. 4
      imports/message-read-receipt/server/hooks.js
  4. 1025
      package-lock.json
  5. 3
      package.json
  6. 3
      packages/rocketchat-api/server/v1/misc.js
  7. 7
      packages/rocketchat-channel-settings/server/methods/saveRoomSettings.js
  8. 1
      packages/rocketchat-federation/README.md
  9. 23
      packages/rocketchat-federation/client/main.js
  10. 23
      packages/rocketchat-federation/package.js
  11. 264
      packages/rocketchat-federation/server/federatedResources/FederatedMessage.js
  12. 19
      packages/rocketchat-federation/server/federatedResources/FederatedResource.js
  13. 270
      packages/rocketchat-federation/server/federatedResources/FederatedRoom.js
  14. 124
      packages/rocketchat-federation/server/federatedResources/FederatedUser.js
  15. 4
      packages/rocketchat-federation/server/federatedResources/index.js
  16. 68
      packages/rocketchat-federation/server/federation-settings.js
  17. 12
      packages/rocketchat-federation/server/logger.js
  18. 152
      packages/rocketchat-federation/server/main.js
  19. 48
      packages/rocketchat-federation/server/methods/federationAddUser.js
  20. 24
      packages/rocketchat-federation/server/methods/federationSearchUsers.js
  21. 2
      packages/rocketchat-federation/server/methods/index.js
  22. 614
      packages/rocketchat-federation/server/peerClient.js
  23. 175
      packages/rocketchat-federation/server/peerDNS.js
  24. 126
      packages/rocketchat-federation/server/peerHTTP.js
  25. 8
      packages/rocketchat-federation/server/peerServer/index.js
  26. 388
      packages/rocketchat-federation/server/peerServer/peerServer.js
  27. 106
      packages/rocketchat-federation/server/peerServer/routes/events.js
  28. 28
      packages/rocketchat-federation/server/peerServer/routes/uploads.js
  29. 49
      packages/rocketchat-federation/server/peerServer/routes/users.js
  30. 17
      packages/rocketchat-federation/server/settingsUpdater.js
  31. 4
      packages/rocketchat-file-upload/lib/FileUpload.js
  32. 17
      packages/rocketchat-file-upload/server/lib/FileUpload.js
  33. 18
      packages/rocketchat-i18n/i18n/en.i18n.json
  34. 8
      packages/rocketchat-lib/server/functions/addUserToRoom.js
  35. 65
      packages/rocketchat-lib/server/functions/createRoom.js
  36. 10
      packages/rocketchat-lib/server/functions/removeUserFromRoom.js
  37. 1134
      packages/rocketchat-livechat/.app/package-lock.json
  38. 6
      packages/rocketchat-mentions/lib/Mentions.js
  39. 3
      packages/rocketchat-models/server/index.js
  40. 13
      packages/rocketchat-models/server/models/FederationDNSCache.js
  41. 255
      packages/rocketchat-models/server/models/FederationEvents.js
  42. 69
      packages/rocketchat-models/server/models/FederationKeys.js
  43. 10
      packages/rocketchat-models/server/models/Messages.js
  44. 6
      packages/rocketchat-models/server/models/Users.js
  45. 2
      packages/rocketchat-reactions/server/index.js
  46. 141
      packages/rocketchat-reactions/server/setReaction.js
  47. 3
      packages/rocketchat-slashcommands-invite/server/server.js
  48. 37
      packages/rocketchat-ui/client/views/app/directory.html
  49. 101
      packages/rocketchat-ui/client/views/app/directory.js
  50. 16
      packages/rocketchat-utils/lib/getValidRoomName.js
  51. 21
      packages/rocketchat_theme/client/imports/components/modal/directory.css
  52. 89
      server/methods/browseChannels.js
  53. 10
      server/methods/createDirectMessage.js
  54. 11
      server/methods/muteUserInRoom.js
  55. 9
      server/methods/readMessages.js
  56. 11
      server/methods/removeUserFromRoom.js
  57. 11
      server/methods/unmuteUserInRoom.js

@ -64,6 +64,7 @@ rocketchat:emoji-custom
rocketchat:emoji-emojione
rocketchat:error-handler
rocketchat:favico
rocketchat:federation
rocketchat:file
rocketchat:file-upload
rocketchat:github-enterprise

@ -159,6 +159,7 @@ rocketchat:emoji-custom@1.0.0
rocketchat:emoji-emojione@0.0.1
rocketchat:error-handler@1.0.0
rocketchat:favico@0.0.1
rocketchat:federation@0.0.1
rocketchat:file@0.0.1
rocketchat:file-upload@0.0.1
rocketchat:github-enterprise@0.0.1

@ -15,3 +15,7 @@ callbacks.add('afterSaveMessage', (message, room) => {
// mark message as read as well
ReadReceipt.markMessageAsReadBySender(message, room._id, message.u._id);
});
callbacks.add('afterReadMessages', (rid, { userId, lastSeen }) => {
ReadReceipt.markMessagesAsRead(rid, userId, lastSeen);
});

1025
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -194,6 +194,7 @@
"moment": "^2.22.2",
"moment-timezone": "^0.5.23",
"node-dogstatsd": "^0.0.7",
"node-rsa": "^1.0.3",
"object-path": "^0.11.4",
"pdfjs-dist": "^2.0.943",
"photoswipe": "^4.1.2",
@ -205,6 +206,7 @@
"semver": "^5.6.0",
"sharp": "^0.21.0",
"speakeasy": "^2.0.0",
"stream-buffers": "^3.0.2",
"subscriptions-transport-ws": "^0.9.11",
"tar-stream": "^1.6.2",
"toastr": "^2.1.4",
@ -214,6 +216,7 @@
"ua-parser-js": "^0.7.19",
"underscore": "^1.9.1",
"underscore.string": "^3.3.5",
"uuid": "^3.3.2",
"webdav": "^2.0.0",
"wolfy87-eventemitter": "^5.2.5",
"xml-crypto": "^1.0.2",

@ -152,7 +152,7 @@ API.v1.addRoute('directory', { authRequired: true }, {
const { offset, count } = this.getPaginationItems();
const { sort, query } = this.parseJsonQuery();
const { text, type } = query;
const { text, type, workspace = 'local' } = query;
if (sort && Object.keys(sort).length > 1) {
return API.v1.failure('This method support only one "sort" parameter');
}
@ -162,6 +162,7 @@ API.v1.addRoute('directory', { authRequired: true }, {
const result = Meteor.runAsUser(this.userId, () => Meteor.call('browseChannels', {
text,
type,
workspace,
sortBy,
sortDirection,
offset: Math.max(0, offset),

@ -2,6 +2,8 @@ import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { hasPermission } from 'meteor/rocketchat:authorization';
import { Rooms } from 'meteor/rocketchat:models';
import { callbacks } from 'meteor/rocketchat:callbacks';
import { saveRoomName } from '../functions/saveRoomName';
import { saveRoomTopic } from '../functions/saveRoomTopic';
import { saveRoomAnnouncement } from '../functions/saveRoomAnnouncement';
@ -212,6 +214,11 @@ Meteor.methods({
}
});
Meteor.defer(function() {
const room = Rooms.findOneById(rid);
callbacks.run('afterSaveRoomSettings', room);
});
return {
result: true,
rid: room._id,

@ -0,0 +1 @@
##Rocket.Chat Federation

@ -0,0 +1,23 @@
import { MessageTypes } from 'meteor/rocketchat:ui-utils';
// Register message types
MessageTypes.registerType({
id: 'rejected-message-by-peer',
system: true,
message: 'This_message_was_rejected_by__peer__peer',
data(message) {
return {
peer: message.peer,
};
},
});
MessageTypes.registerType({
id: 'peer-does-not-exist',
system: true,
message: 'The_peer__peer__does_not_exist',
data(message) {
return {
peer: message.peer,
};
},
});

@ -0,0 +1,23 @@
Package.describe({
name: 'rocketchat:federation',
version: '0.0.1',
summary: 'RocketChat support for federating with other RocketChat servers',
git: '',
});
Package.onUse(function(api) {
api.use([
'ecmascript',
'rocketchat:api',
'rocketchat:lib',
'rocketchat:reactions',
'rocketchat:models',
'rocketchat:settings',
]);
api.use('accounts-base', 'server');
api.use('accounts-password', 'server');
api.mainModule('client/main.js', 'client');
api.mainModule('server/main.js', 'server');
});

@ -0,0 +1,264 @@
import { Meteor } from 'meteor/meteor';
import { sendMessage, updateMessage } from 'meteor/rocketchat:lib';
import { Messages, Rooms, Users } from 'meteor/rocketchat:models';
import { FileUpload } from 'meteor/rocketchat:file-upload';
import FederatedResource from './FederatedResource';
import FederatedRoom from './FederatedRoom';
import FederatedUser from './FederatedUser';
import peerClient from '../peerClient';
class FederatedMessage extends FederatedResource {
constructor(localPeerIdentifier, message) {
super('message');
if (!message) {
throw new Error('message param cannot be empty');
}
this.localPeerIdentifier = localPeerIdentifier;
// Make sure room dates are correct
message.ts = new Date(message.ts);
message._updatedAt = new Date(message._updatedAt);
// Set the message author
if (message.u.federation) {
this.federatedAuthor = FederatedUser.loadByFederationId(localPeerIdentifier, message.u.federation._id);
} else {
const author = Users.findOneById(message.u._id);
this.federatedAuthor = new FederatedUser(localPeerIdentifier, author);
}
message.u = {
username: this.federatedAuthor.user.username,
federation: {
_id: this.federatedAuthor.user.federation._id,
},
};
// Set the room
const room = Rooms.findOneById(message.rid);
// Prepare the federation property
if (!message.federation) {
const federation = {
_id: message._id,
peer: localPeerIdentifier,
roomId: room.federation._id,
};
// Prepare the user
message.federation = federation;
// Update the user
Messages.update(message._id, { $set: { federation } });
// Prepare mentions
for (const mention of message.mentions) {
mention.federation = mention.federation || {};
if (mention.username.indexOf('@') === -1) {
mention.federation.peer = localPeerIdentifier;
} else {
const [username, peer] = mention.username.split('@');
mention.username = username;
mention.federation.peer = peer;
}
}
// Prepare channels
for (const channel of message.channels) {
channel.federation = channel.federation || {};
if (channel.name.indexOf('@') === -1) {
channel.federation.peer = localPeerIdentifier;
} else {
channel.name = channel.name.split('@')[0];
channel.federation.peer = channel.name.split('@')[1];
}
}
}
// Set message property
this.message = message;
}
getFederationId() {
return this.message.federation._id;
}
getMessage() {
return this.message;
}
getLocalMessage() {
this.log('getLocalMessage');
const { localPeerIdentifier, message } = this;
const localMessage = Object.assign({}, message);
// Make sure `u` is correct
if (!this.federatedAuthor) {
throw new Error('Author does not exist');
}
const localAuthor = this.federatedAuthor.getLocalUser();
localMessage.u = {
_id: localAuthor._id,
username: localAuthor.username,
};
// Make sure `rid` is correct
const federatedRoom = FederatedRoom.loadByFederationId(localPeerIdentifier, message.federation.roomId);
if (!federatedRoom) {
throw new Error('Room does not exist');
}
const localRoom = federatedRoom.getLocalRoom();
localMessage.rid = localRoom._id;
return localMessage;
}
create() {
this.log('create');
// Get the local message object
const localMessageObject = this.getLocalMessage();
// Grab the federation id
const { federation: { _id: federationId } } = localMessageObject;
// Check if the message exists
let localMessage = Messages.findOne({ 'federation._id': federationId });
// Create if needed
if (!localMessage) {
delete localMessageObject._id;
localMessage = localMessageObject;
const localRoom = Rooms.findOneById(localMessage.rid);
// Normalize mentions
for (const mention of localMessage.mentions) {
// Ignore if we are dealing with all, here or rocket.cat
if (['all', 'here', 'rocket.cat'].indexOf(mention.username) !== -1) { continue; }
let usernameToReplace = '';
if (mention.federation.peer !== this.localPeerIdentifier) {
usernameToReplace = mention.username;
mention.username = `${ mention.username }@${ mention.federation.peer }`;
} else {
usernameToReplace = `${ mention.username }@${ mention.federation.peer }`;
}
localMessage.msg = localMessage.msg.split(usernameToReplace).join(mention.username);
}
// Normalize channels
for (const channel of localMessage.channels) {
if (channel.federation.peer !== this.localPeerIdentifier) {
channel.name = `${ channel.name }@${ channel.federation.peer }`;
}
}
// Is there a file?
if (localMessage.file) {
const fileStore = FileUpload.getStore('Uploads');
const { federation: { peer: identifier } } = localMessage;
const { upload, buffer } = peerClient.getUpload({ identifier, localMessage });
const oldUploadId = upload._id;
// Normalize upload
delete upload._id;
upload.rid = localMessage.rid;
upload.userId = localMessage.u._id;
upload.federation = {
_id: localMessage.file._id,
peer: identifier,
};
Meteor.runAsUser(upload.userId, () => Meteor.wrapAsync(fileStore.insert.bind(fileStore))(upload, buffer));
// Update the message's file
localMessage.file._id = upload._id;
// Update the message's attachments
for (const attachment of localMessage.attachments) {
attachment.title_link = attachment.title_link.replace(oldUploadId, upload._id);
attachment.image_url = attachment.image_url.replace(oldUploadId, upload._id);
}
}
// Create the message
const { _id } = sendMessage(localMessage.u, localMessage, localRoom, false);
localMessage._id = _id;
}
return localMessage;
}
update(updatedByFederatedUser) {
this.log('update');
// Get the original message
const originalMessage = Messages.findOne({ 'federation._id': this.getFederationId() });
// Error if message does not exist
if (!originalMessage) {
throw new Error('Message does not exist');
}
// Get the local message object
const localMessage = this.getLocalMessage();
// Make sure the message has the correct _id
localMessage._id = originalMessage._id;
// Get the user who updated
const user = updatedByFederatedUser.getLocalUser();
// Update the message
updateMessage(localMessage, user, originalMessage);
return localMessage;
}
}
FederatedMessage.loadByFederationId = function loadByFederationId(localPeerIdentifier, federationId) {
const localMessage = Messages.findOne({ 'federation._id': federationId });
if (!localMessage) { return; }
return new FederatedMessage(localPeerIdentifier, localMessage);
};
FederatedMessage.loadOrCreate = function loadOrCreate(localPeerIdentifier, message) {
const { federation } = message;
if (federation) {
const federatedMessage = FederatedMessage.loadByFederationId(localPeerIdentifier, federation._id);
if (federatedMessage) {
return federatedMessage;
}
}
return new FederatedMessage(localPeerIdentifier, message);
};
export default FederatedMessage;

@ -0,0 +1,19 @@
import { logger } from '../logger';
class FederatedResource {
constructor(name) {
this.resourceName = `federated-${ name }`;
this.log('Creating federated resource');
}
log(message) {
FederatedResource.log(this.resourceName, message);
}
}
FederatedResource.log = function log(name, message) {
logger.resource.info(`[${ name }] ${ message }`);
};
export default FederatedResource;

@ -0,0 +1,270 @@
import { createRoom } from 'meteor/rocketchat:lib';
import { Rooms, Subscriptions, Users } from 'meteor/rocketchat:models';
import FederatedResource from './FederatedResource';
import FederatedUser from './FederatedUser';
class FederatedRoom extends FederatedResource {
constructor(localPeerIdentifier, room, extras = {}) {
super('room');
if (!room) {
throw new Error('room param cannot be empty');
}
this.localPeerIdentifier = localPeerIdentifier;
// Make sure room dates are correct
room.ts = new Date(room.ts);
room._updatedAt = new Date(room._updatedAt);
// Set the name
if (room.t !== 'd' && room.name.indexOf('@') === -1) {
room.name = `${ room.name }@${ localPeerIdentifier }`;
}
// Set the federated owner, if there is one
const { owner } = extras;
if (owner) {
if (!owner && room.federation) {
this.federatedOwner = FederatedUser.loadByFederationId(localPeerIdentifier, room.federation.ownerId);
} else {
this.federatedOwner = FederatedUser.loadOrCreate(localPeerIdentifier, owner);
}
}
// Set base federation
room.federation = room.federation || {
_id: room._id,
peer: localPeerIdentifier,
ownerId: this.federatedOwner ? this.federatedOwner.getFederationId() : null,
};
// Set room property
this.room = room;
}
getFederationId() {
return this.room.federation._id;
}
getPeers() {
return this.room.federation.peers;
}
getRoom() {
return this.room;
}
getOwner() {
return this.federatedOwner ? this.federatedOwner.getUser() : null;
}
getUsers() {
return this.federatedUsers.map((u) => u.getUser());
}
loadUsers() {
const { room } = this;
// Get all room users
const users = FederatedRoom.loadRoomUsers(room);
this.setUsers(users);
}
setUsers(users) {
const { localPeerIdentifier } = this;
// Initialize federatedUsers
this.federatedUsers = [];
for (const user of users) {
const federatedUser = FederatedUser.loadOrCreate(localPeerIdentifier, user);
// Keep the federated user
this.federatedUsers.push(federatedUser);
}
}
refreshFederation() {
const { room } = this;
// Prepare the federated users
let federation = {
peers: [],
users: [],
};
// Check all the peers
for (const federatedUser of this.federatedUsers) {
// Add federation data to the room
const { user: { federation: { _id, peer } } } = federatedUser;
federation.peers.push(peer);
federation.users.push({ _id, peer });
}
federation.peers = [...new Set(federation.peers)];
federation = Object.assign(room.federation || {}, federation);
// Prepare the room
room.federation = federation;
// Update the room
Rooms.update(room._id, { $set: { federation } });
}
getLocalRoom() {
this.log('getLocalRoom');
const { localPeerIdentifier, room, room: { federation } } = this;
const localRoom = Object.assign({}, room);
if (federation.peer === localPeerIdentifier) {
if (localRoom.t !== 'd') {
localRoom.name = room.name.split('@')[0];
}
}
return localRoom;
}
createUsers() {
this.log('createUsers');
const { federatedUsers } = this;
// Create, if needed, all room's users
for (const federatedUser of federatedUsers) {
federatedUser.create();
}
}
create() {
this.log('create');
// Get the local room object (with or without suffixes)
const localRoomObject = this.getLocalRoom();
// Grab the federation id
const { federation: { _id: federationId } } = localRoomObject;
// Check if the user exists
let localRoom = FederatedRoom.loadByFederationId(this.localPeerIdentifier, federationId);
// Create if needed
if (!localRoom) {
delete localRoomObject._id;
localRoom = localRoomObject;
const { t: type, name, broadcast, customFields, federation, sysMes } = localRoom;
const { federatedOwner, federatedUsers } = this;
// Get usernames for the owner and members
const ownerUsername = federatedOwner.user.username;
const members = [];
if (type !== 'd') {
for (const federatedUser of federatedUsers) {
const localUser = federatedUser.getLocalUser();
members.push(localUser.username);
}
} else {
for (const federatedUser of federatedUsers) {
const localUser = federatedUser.getLocalUser();
members.push(localUser);
}
}
// Is this a broadcast channel? Then mute everyone but the owner
let muted = [];
if (broadcast) {
muted = members.filter((u) => u !== ownerUsername);
}
// Set the extra data and create room options
let extraData = {
federation,
};
let createRoomOptions = {
subscriptionExtra: {
alert: true,
open: true,
},
};
if (type !== 'd') {
extraData = Object.assign(extraData, {
broadcast,
customFields,
encrypted: false, // Always false for now
muted,
sysMes,
});
createRoomOptions = Object.assign(extraData, {
nameValidationRegex: '^[0-9a-zA-Z-_.@]+$',
subscriptionExtra: {
alert: true,
},
});
}
// Create the room
// !!!! Forcing direct or private only, no public rooms for now
const { rid } = createRoom(type === 'd' ? type : 'p', name, ownerUsername, members, false, extraData, createRoomOptions);
localRoom._id = rid;
}
return localRoom;
}
}
FederatedRoom.loadByFederationId = function loadByFederationId(localPeerIdentifier, federationId) {
const localRoom = Rooms.findOne({ 'federation._id': federationId });
if (!localRoom) { return; }
return new FederatedRoom(localPeerIdentifier, localRoom);
};
FederatedRoom.loadRoomUsers = function loadRoomUsers(room) {
const subscriptions = Subscriptions.findByRoomIdWhenUsernameExists(room._id, { fields: { 'u._id': 1 } }).fetch();
const userIds = subscriptions.map((s) => s.u._id);
return Users.findUsersWithUsernameByIds(userIds).fetch();
};
FederatedRoom.isFederated = function isFederated(localPeerIdentifier, room, options = {}) {
this.log('federated-room', `${ room._id } - isFederated?`);
let isFederated = false;
if (options.checkUsingUsers) {
// Get all room users
const users = FederatedRoom.loadRoomUsers(room);
// Check all the users
for (const user of users) {
if (user.federation && user.federation.peer !== localPeerIdentifier) {
isFederated = true;
break;
}
}
} else {
isFederated = room.federation && room.federation.peers.length > 1;
}
this.log('federated-room', `${ room._id } - isFederated? ${ isFederated ? 'yes' : 'no' }`);
return isFederated;
};
export default FederatedRoom;

@ -0,0 +1,124 @@
import { Users } from 'meteor/rocketchat:models';
import FederatedResource from './FederatedResource';
class FederatedUser extends FederatedResource {
constructor(localPeerIdentifier, user) {
super('user');
if (!user) {
throw new Error('user param cannot be empty');
}
this.localPeerIdentifier = localPeerIdentifier;
// Make sure all properties are normalized
// Prepare the federation property
if (!user.federation) {
const federation = {
_id: user._id,
peer: localPeerIdentifier,
};
// Prepare the user
user.federation = federation;
// Update the user
Users.update(user._id, { $set: { federation } });
}
// Make sure user dates are correct
user.createdAt = new Date(user.createdAt);
user.lastLogin = new Date(user.lastLogin);
user._updatedAt = new Date(user._updatedAt);
// Delete sensitive data as well
delete user.roles;
delete user.services;
// Make sure some other properties are ready
user.name = user.name;
user.username = user.username.indexOf('@') === -1 ? `${ user.username }@${ user.federation.peer }` : user.username;
user.roles = ['user'];
user.status = 'online';
user.statusConnection = 'online';
user.type = 'user';
// Set user property
this.user = user;
}
getFederationId() {
return this.user.federation._id;
}
getUser() {
return this.user;
}
getLocalUser() {
this.log('getLocalUser');
const { localPeerIdentifier, user, user: { federation } } = this;
const localUser = Object.assign({}, user);
if (federation.peer === localPeerIdentifier || user.username === 'rocket.cat') {
localUser.username = user.username.split('@')[0];
localUser.name = user.name.split('@')[0];
}
return localUser;
}
create() {
this.log('create');
// Get the local user object (with or without suffixes)
const localUserObject = this.getLocalUser();
// Grab the federation id
const { federation: { _id: federationId } } = localUserObject;
// Check if the user exists
let localUser = Users.findOne({ 'federation._id': federationId });
// Create if needed
if (!localUser) {
delete localUserObject._id;
localUser = localUserObject;
localUser._id = Users.create(localUserObject);
}
// Update the id
this.user._id = localUser._id;
return localUser;
}
}
FederatedUser.loadByFederationId = function loadByFederationId(localPeerIdentifier, federationId) {
const localUser = Users.findOne({ 'federation._id': federationId });
if (!localUser) { return; }
return new FederatedUser(localPeerIdentifier, localUser);
};
FederatedUser.loadOrCreate = function loadOrCreate(localPeerIdentifier, user) {
const { federation } = user;
if (federation) {
const federatedUser = FederatedUser.loadByFederationId(localPeerIdentifier, federation._id);
if (federatedUser) {
return federatedUser;
}
}
return new FederatedUser(localPeerIdentifier, user);
};
export default FederatedUser;

@ -0,0 +1,4 @@
export { default as FederatedMessage } from './FederatedMessage';
export { default as FederatedResource } from './FederatedResource';
export { default as FederatedRoom } from './FederatedRoom';
export { default as FederatedUser } from './FederatedUser';

@ -0,0 +1,68 @@
import { Meteor } from 'meteor/meteor';
import { settings } from 'meteor/rocketchat:settings';
import { FederationKeys } from 'meteor/rocketchat:models';
Meteor.startup(function() {
// const federationUniqueId = FederationKeys.getUniqueId();
const federationPublicKey = FederationKeys.getPublicKeyString();
settings.addGroup('Federation', function() {
this.add('FEDERATION_Enabled', false, {
type: 'boolean',
i18nLabel: 'Enabled',
i18nDescription: 'FEDERATION_Enabled',
alert: 'FEDERATION_Enabled_Alert',
public: true,
});
this.add('FEDERATION_Status', '-', {
readonly: true,
type: 'string',
i18nLabel: 'FEDERATION_Status',
});
// this.add('FEDERATION_Unique_Id', federationUniqueId, {
// readonly: true,
// type: 'string',
// i18nLabel: 'FEDERATION_Unique_Id',
// i18nDescription: 'FEDERATION_Unique_Id_Description',
// });
this.add('FEDERATION_Domain', '', {
type: 'string',
i18nLabel: 'FEDERATION_Domain',
i18nDescription: 'FEDERATION_Domain_Description',
alert: 'FEDERATION_Domain_Alert',
});
this.add('FEDERATION_Public_Key', federationPublicKey, {
readonly: true,
type: 'string',
multiline: true,
i18nLabel: 'FEDERATION_Public_Key',
i18nDescription: 'FEDERATION_Public_Key_Description',
});
this.add('FEDERATION_Hub_URL', 'https://hub.rocket.chat', {
group: 'Federation Hub',
type: 'string',
i18nLabel: 'FEDERATION_Hub_URL',
i18nDescription: 'FEDERATION_Hub_URL_Description',
});
this.add('FEDERATION_Discovery_Method', 'dns', {
type: 'select',
values: [{
key: 'dns',
i18nLabel: 'DNS',
}, {
key: 'hub',
i18nLabel: 'Hub',
}],
i18nLabel: 'FEDERATION_Discovery_Method',
i18nDescription: 'FEDERATION_Discovery_Method_Description',
public: true,
});
});
});

@ -0,0 +1,12 @@
import { Logger } from 'meteor/rocketchat:logger';
export const logger = new Logger('Federation', {
sections: {
resource: 'Resource',
setup: 'Setup',
peerClient: 'Peer Client',
peerServer: 'Peer Server',
dns: 'DNS',
http: 'HTTP',
},
});

@ -0,0 +1,152 @@
import { Meteor } from 'meteor/meteor';
import { _ } from 'meteor/underscore';
import { settings } from 'meteor/rocketchat:settings';
import { FederationKeys } from 'meteor/rocketchat:models';
import './federation-settings';
import './methods';
import { logger } from './logger';
import peerClient from './peerClient';
import peerServer from './peerServer';
import peerDNS from './peerDNS';
import peerHTTP from './peerHTTP';
import * as SettingsUpdater from './settingsUpdater';
export const Federation = {
enabled: false,
privateKey: null,
publicKey: null,
usingHub: null,
uniqueId: null,
localIdentifier: null,
};
// Generate keys
// Create unique id if needed
if (!FederationKeys.getUniqueId()) {
FederationKeys.generateUniqueId();
}
// Create key pair if needed
if (!FederationKeys.getPublicKey()) {
FederationKeys.generateKeys();
}
// Initializations
// Start the client, setting up all the callbacks
peerClient.start();
// Start the server, setting up all the endpoints
peerServer.start();
const updateSettings = _.debounce(Meteor.bindEnvironment(function() {
const _enabled = settings.get('FEDERATION_Enabled');
if (!_enabled) { return; }
// If it is enabled, check if the settings are there
const _uniqueId = settings.get('FEDERATION_Unique_Id');
const _domain = settings.get('FEDERATION_Domain');
const _discoveryMethod = settings.get('FEDERATION_Discovery_Method');
const _hubUrl = settings.get('FEDERATION_Hub_URL');
const _peerUrl = settings.get('Site_Url');
if (!_domain || !_discoveryMethod || !_hubUrl || !_peerUrl) {
SettingsUpdater.updateStatus('Could not enable, settings are not fully set');
logger.setup.error('Could not enable Federation, settings are not fully set');
return;
}
logger.setup.info('Updating settings...');
// Normalize the config values
const config = {
hub: {
active: _discoveryMethod === 'hub',
url: _hubUrl.replace(/\/+$/, ''),
},
peer: {
uniqueId: _uniqueId,
domain: _domain.replace('@', '').trim(),
url: _peerUrl.replace(/\/+$/, ''),
public_key: FederationKeys.getPublicKeyString(),
},
};
// If the settings are correctly set, let's update the configuration
// Get the key pair
Federation.privateKey = FederationKeys.getPrivateKey();
Federation.publicKey = FederationKeys.getPublicKey();
// Set important information
Federation.enabled = true;
Federation.usingHub = config.hub.active;
Federation.uniqueId = config.peer.uniqueId;
Federation.localIdentifier = config.peer.domain;
// Set DNS
peerDNS.setConfig(config);
// Set HTTP
peerHTTP.setConfig(config);
// Set Client
peerClient.setConfig(config);
peerClient.enable();
// Set server
peerServer.setConfig(config);
peerServer.enable();
// Register the client
if (peerClient.register()) {
SettingsUpdater.updateStatus('Running');
} else {
SettingsUpdater.updateNextStatusTo('Disabled, could not register with Hub');
SettingsUpdater.updateEnabled(false);
}
}), 150);
function enableOrDisable() {
const _enabled = settings.get('FEDERATION_Enabled');
// If it was enabled, and was disabled now,
// make sure we disable everything: callbacks and endpoints
if (Federation.enabled && !_enabled) {
peerClient.disable();
peerServer.disable();
// Disable federation
Federation.enabled = false;
SettingsUpdater.updateStatus('Disabled');
logger.setup.info('Shutting down...');
return;
}
// If not enabled, skip
if (!_enabled) {
SettingsUpdater.updateStatus('Disabled');
return;
}
logger.setup.info('Booting...');
SettingsUpdater.updateStatus('Booting...');
updateSettings();
}
// Add settings listeners
settings.get('FEDERATION_Enabled', enableOrDisable);
settings.get('FEDERATION_Domain', updateSettings);
settings.get('FEDERATION_Discovery_Method', updateSettings);
settings.get('FEDERATION_Hub_URL', updateSettings);

@ -0,0 +1,48 @@
import { Meteor } from 'meteor/meteor';
import { Users } from 'meteor/rocketchat:models';
import { logger } from '../logger';
import peerClient from '../peerClient';
import peerServer from '../peerClient';
Meteor.methods({
federationAddUser(emailAddress, domainOverride) {
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'federationAddUser' });
}
if (!peerServer.enabled) {
throw new Meteor.Error('error-federation-disabled', 'Federation disabled', { method: 'federationAddUser' });
}
// Make sure the federated user still exists, and get the unique one, by email address
const [federatedUser] = peerClient.findUsers(emailAddress, { domainOverride, emailOnly: true });
if (!federatedUser) {
throw new Meteor.Error('federation-invalid-user', 'There is no user to add.');
}
let user = null;
const localUser = federatedUser.getLocalUser();
localUser.name += `@${ federatedUser.user.federation.peer }`;
// Delete the _id
delete localUser._id;
try {
// Create the local user
user = Users.create(localUser);
} catch (err) {
// If the user already exists, return the existing user
if (err.code === 11000) {
user = Users.findOne({ 'federation._id': localUser.federation._id });
}
logger.error(err);
}
return user;
},
});

@ -0,0 +1,24 @@
import { Meteor } from 'meteor/meteor';
import peerClient from '../peerClient';
import peerServer from '../peerServer';
Meteor.methods({
federationSearchUsers(email) {
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'federationSearchUsers' });
}
if (!peerServer.enabled) {
throw new Meteor.Error('error-federation-disabled', 'Federation disabled', { method: 'federationAddUser' });
}
const federatedUsers = peerClient.findUsers(email);
if (!federatedUsers.length) {
throw new Meteor.Error('federation-user-not-found', `Could not find federated users using "${ email }"`);
}
return federatedUsers;
},
});

@ -0,0 +1,2 @@
import './federationSearchUsers';
import './federationAddUser';

@ -0,0 +1,614 @@
import qs from 'querystring';
import { Meteor } from 'meteor/meteor';
import { callbacks } from 'meteor/rocketchat:callbacks';
import { settings } from 'meteor/rocketchat:settings';
import { FederationEvents, FederationKeys, Messages, Rooms, Subscriptions, Users } from 'meteor/rocketchat:models';
import { Federation } from './main';
import peerDNS from './peerDNS';
import peerHTTP from './peerHTTP';
import { updateStatus } from './settingsUpdater';
import { logger } from './logger';
import { FederatedMessage, FederatedRoom, FederatedUser } from './federatedResources';
class PeerClient {
constructor() {
this.config = {};
this.enabled = false;
// Keep resources we should skip callbacks
this.callbacksToSkip = {};
}
setConfig(config) {
// General
this.config = config;
// Setup HubPeer
const { hub: { url } } = this.config;
// Remove trailing slash
this.HubPeer = { url };
// Set the local peer
this.peer = {
domain: this.config.peer.domain,
url: this.config.peer.url,
public_key: this.config.peer.public_key,
};
}
log(message) {
logger.peerClient.info(message);
}
disable() {
this.log('Disabling...');
this.enabled = false;
}
enable() {
this.log('Enabling...');
this.enabled = true;
}
start() {
this.setupCallbacks();
}
// ###########
//
// Registering
//
// ###########
register() {
if (this.config.hub.active) {
updateStatus('Registering with Hub...');
return peerDNS.register(this.peer);
}
return true;
}
// ###################
//
// Callback management
//
// ###################
addCallbackToSkip(callback, resourceId) {
this.callbacksToSkip[`${ callback }_${ resourceId }`] = true;
}
skipCallbackIfNeeded(callback, resource) {
const { federation } = resource;
if (!federation) { return false; }
const { _id } = federation;
const callbackName = `${ callback }_${ _id }`;
const skipCallback = this.callbacksToSkip[callbackName];
delete this.callbacksToSkip[callbackName];
this.log(`${ callbackName } callback ${ skipCallback ? '' : 'not ' }skipped`);
return skipCallback;
}
wrapEnabled(callbackHandler) {
return function(...parameters) {
if (!this.enabled) { return; }
callbackHandler.apply(this, parameters);
}.bind(this);
}
setupCallbacks() {
// Accounts.onLogin(onLoginCallbackHandler.bind(this));
// Accounts.onLogout(onLogoutCallbackHandler.bind(this));
FederationEvents.on('createEvent', this.wrapEnabled(this.onCreateEvent.bind(this)));
callbacks.add('afterCreateDirectRoom', this.wrapEnabled(this.afterCreateDirectRoom.bind(this)), callbacks.priority.LOW, 'federation-create-direct-room');
callbacks.add('afterCreateRoom', this.wrapEnabled(this.afterCreateRoom.bind(this)), callbacks.priority.LOW, 'federation-join-room');
callbacks.add('afterSaveRoomSettings', this.wrapEnabled(this.afterSaveRoomSettings.bind(this)), callbacks.priority.LOW, 'federation-after-save-room-settings');
callbacks.add('afterAddedToRoom', this.wrapEnabled(this.afterAddedToRoom.bind(this)), callbacks.priority.LOW, 'federation-join-room');
callbacks.add('beforeLeaveRoom', this.wrapEnabled(this.beforeLeaveRoom.bind(this)), callbacks.priority.LOW, 'federation-leave-room');
callbacks.add('beforeRemoveFromRoom', this.wrapEnabled(this.beforeRemoveFromRoom.bind(this)), callbacks.priority.LOW, 'federation-leave-room');
callbacks.add('afterSaveMessage', this.wrapEnabled(this.afterSaveMessage.bind(this)), callbacks.priority.LOW, 'federation-save-message');
callbacks.add('afterDeleteMessage', this.wrapEnabled(this.afterDeleteMessage.bind(this)), callbacks.priority.LOW, 'federation-delete-message');
callbacks.add('afterReadMessages', this.wrapEnabled(this.afterReadMessages.bind(this)), callbacks.priority.LOW, 'federation-read-messages');
callbacks.add('afterSetReaction', this.wrapEnabled(this.afterSetReaction.bind(this)), callbacks.priority.LOW, 'federation-after-set-reaction');
callbacks.add('afterUnsetReaction', this.wrapEnabled(this.afterUnsetReaction.bind(this)), callbacks.priority.LOW, 'federation-after-unset-reaction');
callbacks.add('afterMuteUser', this.wrapEnabled(this.afterMuteUser.bind(this)), callbacks.priority.LOW, 'federation-mute-user');
callbacks.add('afterUnmuteUser', this.wrapEnabled(this.afterUnmuteUser.bind(this)), callbacks.priority.LOW, 'federation-unmute-user');
this.log('Callbacks set');
}
// ################
//
// Event management
//
// ################
propagateEvent(e) {
this.log(`propagateEvent: ${ e.t }`);
const { peer: domain } = e;
const peer = peerDNS.searchPeer(domain);
if (!peer || !peer.public_key) {
this.log(`Could not find valid peer:${ domain }`);
FederationEvents.setEventAsErrored(e, 'Could not find valid peer');
} else {
try {
const stringPayload = JSON.stringify({ event: e });
// Encrypt with the peer's public key
let payload = FederationKeys.loadKey(peer.public_key, 'public').encrypt(stringPayload);
// Encrypt with the local private key
payload = Federation.privateKey.encryptPrivate(payload);
peerHTTP.request(peer, 'POST', '/api/v1/federation.events', { payload }, { total: 5, stepSize: 500, stepMultiplier: 10 });
FederationEvents.setEventAsFullfilled(e);
} catch (err) {
this.log(`[${ e.t }] Event could not be sent to peer:${ domain }`);
if (err.response) {
const { response: { data: error } } = err;
if (error.errorType === 'error-app-prevented-sending') {
const { payload: {
message: {
rid: roomId,
u: {
username,
federation: { _id: userId },
},
},
} } = e;
const localUsername = username.split('@')[0];
// Create system message
Messages.createRejectedMessageByPeer(roomId, localUsername, {
u: {
_id: userId,
username: localUsername,
},
peer: domain,
});
return FederationEvents.setEventAsErrored(e, err.error, true);
}
}
if (err.error === 'federation-peer-does-not-exist') {
const { payload: {
message: {
rid: roomId,
u: {
username,
federation: { _id: userId },
},
},
} } = e;
const localUsername = username.split('@')[0];
// Create system message
Messages.createPeerDoesNotExist(roomId, localUsername, {
u: {
_id: userId,
username: localUsername,
},
peer: domain,
});
return FederationEvents.setEventAsErrored(e, err.error, true);
}
return FederationEvents.setEventAsErrored(e, `Could not send request to ${ domain }`);
}
}
}
onCreateEvent(e) {
this.propagateEvent(e);
}
resendUnfulfilledEvents() {
// Should we use queues in here?
const events = FederationEvents.getUnfulfilled();
for (const e of events) {
this.propagateEvent(e);
}
}
// #####
//
// Users
//
// #####
findUsers(email, options = {}) {
const [username, domain] = email.split('@');
const { peer: { domain: localPeerDomain } } = this;
let peer = null;
try {
peer = peerDNS.searchPeer(options.domainOverride || domain);
} catch (err) {
this.log(`Could not find peer using domain:${ domain }`);
throw new Meteor.Error('federation-peer-does-not-exist', `Could not find peer using domain:${ domain }`);
}
try {
const { data: { federatedUsers: remoteFederatedUsers } } = peerHTTP.request(peer, 'GET', `/api/v1/federation.users?${ qs.stringify({ username, domain, emailOnly: options.emailOnly }) }`);
const federatedUsers = [];
for (const federatedUser of remoteFederatedUsers) {
federatedUsers.push(new FederatedUser(localPeerDomain, federatedUser.user));
}
return federatedUsers;
} catch (err) {
this.log(`Could not find user:${ username } at ${ peer.domain }`);
throw new Meteor.Error('federation-user-does-not-exist', `Could not find user:${ email } at ${ peer.domain }`);
}
}
// #######
//
// Uploads
//
// #######
getUpload(options) {
const { identifier: domain, localMessage: { file: { _id: fileId } } } = options;
let peer = null;
try {
peer = peerDNS.searchPeer(domain);
} catch (err) {
this.log(`Could not find peer using domain:${ domain }`);
throw new Meteor.Error('federation-peer-does-not-exist', `Could not find peer using domain:${ domain }`);
}
const { data: { upload, buffer } } = peerHTTP.request(peer, 'GET', `/api/v1/federation.uploads?${ qs.stringify({ upload_id: fileId }) }`);
return { upload, buffer: Buffer.from(buffer) };
}
// #################
//
// Callback handlers
//
// #################
afterCreateDirectRoom(room, { from: owner }) {
this.log('afterCreateDirectRoom');
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room, { checkUsingUsers: true })) { return room; }
const federatedRoom = new FederatedRoom(localPeerDomain, room, { owner });
// Check if this should be skipped
if (this.skipCallbackIfNeeded('afterCreateDirectRoom', federatedRoom.getLocalRoom())) { return room; }
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
FederationEvents.directRoomCreated(federatedRoom, { skipPeers: [localPeerDomain] });
}
afterCreateRoom(roomOwner, room) {
this.log('afterCreateRoom');
const { _id: ownerId } = roomOwner;
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room, { checkUsingUsers: true })) { return roomOwner; }
const owner = Users.findOneById(ownerId);
const federatedRoom = new FederatedRoom(localPeerDomain, room, { owner });
// Check if this should be skipped
if (this.skipCallbackIfNeeded('afterCreateRoom', federatedRoom.getLocalRoom())) { return roomOwner; }
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
FederationEvents.roomCreated(federatedRoom, { skipPeers: [localPeerDomain] });
}
afterSaveRoomSettings(/* room */) {
this.log('afterSaveRoomSettings - NOT IMPLEMENTED');
}
afterAddedToRoom(users, room) {
this.log('afterAddedToRoom');
const { user: userWhoJoined, inviter: userWhoInvited } = users;
// Check if this should be skipped
if (this.skipCallbackIfNeeded('afterAddedToRoom', userWhoJoined)) { return users; }
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room, { checkUsingUsers: true })) { return users; }
const extras = {};
// If the room is not federated and has an owner
if (!room.federation) {
let ownerId;
// If the room does not have an owner, get the first user subscribed to that room
if (!room.u) {
const userSubscription = Subscriptions.findOne({ rid: room._id }, {
sort: {
ts: 1,
},
});
ownerId = userSubscription.u._id;
} else {
ownerId = room.u._id;
}
extras.owner = Users.findOneById(ownerId);
}
const federatedRoom = new FederatedRoom(localPeerDomain, room, extras);
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
// If the user who joined is from a different peer...
if (userWhoJoined.federation && userWhoJoined.federation.peer !== localPeerDomain) {
// ...create a "create room" event for that peer
FederationEvents.roomCreated(federatedRoom, { peers: [userWhoJoined.federation.peer] });
}
// Then, create a "user join/added" event to the other peers
const federatedUserWhoJoined = FederatedUser.loadOrCreate(localPeerDomain, userWhoJoined);
if (userWhoInvited) {
const federatedInviter = FederatedUser.loadOrCreate(localPeerDomain, userWhoInvited);
FederationEvents.userAdded(federatedRoom, federatedUserWhoJoined, federatedInviter, { skipPeers: [localPeerDomain] });
} else {
FederationEvents.userJoined(federatedRoom, federatedUserWhoJoined, { skipPeers: [localPeerDomain] });
}
}
beforeLeaveRoom(userWhoLeft, room) {
this.log('beforeLeaveRoom');
// Check if this should be skipped
if (this.skipCallbackIfNeeded('beforeLeaveRoom', userWhoLeft)) { return userWhoLeft; }
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return userWhoLeft; }
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
const federatedUserWhoLeft = FederatedUser.loadByFederationId(localPeerDomain, userWhoLeft.federation._id);
// Then, create a "user left" event to the other peers
FederationEvents.userLeft(federatedRoom, federatedUserWhoLeft, { skipPeers: [localPeerDomain] });
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
}
beforeRemoveFromRoom(users, room) {
this.log('beforeRemoveFromRoom');
const { removedUser, userWhoRemoved } = users;
// Check if this should be skipped
if (this.skipCallbackIfNeeded('beforeRemoveFromRoom', removedUser)) { return users; }
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return users; }
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
const federatedRemovedUser = FederatedUser.loadByFederationId(localPeerDomain, removedUser.federation._id);
const federatedUserWhoRemoved = FederatedUser.loadByFederationId(localPeerDomain, userWhoRemoved.federation._id);
FederationEvents.userRemoved(federatedRoom, federatedRemovedUser, federatedUserWhoRemoved, { skipPeers: [localPeerDomain] });
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
}
afterSaveMessage(message, room) {
this.log('afterSaveMessage');
// Check if this should be skipped
if (this.skipCallbackIfNeeded('afterSaveMessage', message)) { return message; }
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return message; }
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
const federatedMessage = FederatedMessage.loadOrCreate(localPeerDomain, message);
// If editedAt exists, it means it is an update
if (message.editedAt) {
const user = Users.findOneById(message.editedBy._id);
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, user.federation._id);
FederationEvents.messageUpdated(federatedRoom, federatedMessage, federatedUser, { skipPeers: [localPeerDomain] });
} else {
FederationEvents.messageCreated(federatedRoom, federatedMessage, { skipPeers: [localPeerDomain] });
}
}
afterDeleteMessage(message) {
this.log('afterDeleteMessage');
// Check if this should be skipped
if (this.skipCallbackIfNeeded('afterDeleteMessage', message)) { return message; }
const { peer: { domain: localPeerDomain } } = this;
const room = Rooms.findOneById(message.rid);
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return message; }
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
const federatedMessage = new FederatedMessage(localPeerDomain, message);
FederationEvents.messageDeleted(federatedRoom, federatedMessage, { skipPeers: [localPeerDomain] });
}
afterReadMessages(roomId, { userId }) {
this.log('afterReadMessages');
if (!settings.get('Message_Read_Receipt_Enabled')) { this.log('Skipping: read receipts are not enabled'); return roomId; }
const { peer: { domain: localPeerDomain } } = this;
const room = Rooms.findOneById(roomId);
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return roomId; }
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
if (this.skipCallbackIfNeeded('afterReadMessages', federatedRoom.getLocalRoom())) { return roomId; }
const user = Users.findOneById(userId);
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, user.federation._id);
FederationEvents.messagesRead(federatedRoom, federatedUser, { skipPeers: [localPeerDomain] });
}
afterSetReaction(message, { user, reaction, shouldReact }) {
this.log('afterSetReaction');
const room = Rooms.findOneById(message.rid);
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return message; }
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, user.federation._id);
const federatedMessage = FederatedMessage.loadByFederationId(localPeerDomain, message.federation._id);
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
FederationEvents.messagesSetReaction(federatedRoom, federatedMessage, federatedUser, reaction, shouldReact, { skipPeers: [localPeerDomain] });
}
afterUnsetReaction(message, { user, reaction, shouldReact }) {
this.log('afterUnsetReaction');
const room = Rooms.findOneById(message.rid);
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return message; }
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, user.federation._id);
const federatedMessage = FederatedMessage.loadByFederationId(localPeerDomain, message.federation._id);
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
FederationEvents.messagesUnsetReaction(federatedRoom, federatedMessage, federatedUser, reaction, shouldReact, { skipPeers: [localPeerDomain] });
}
afterMuteUser(users, room) {
this.log('afterMuteUser');
const { mutedUser, fromUser } = users;
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return users; }
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
const federatedMutedUser = FederatedUser.loadByFederationId(localPeerDomain, mutedUser.federation._id);
const federatedUserWhoMuted = FederatedUser.loadByFederationId(localPeerDomain, fromUser.federation._id);
FederationEvents.userMuted(federatedRoom, federatedMutedUser, federatedUserWhoMuted, { skipPeers: [localPeerDomain] });
}
afterUnmuteUser(users, room) {
this.log('afterUnmuteUser');
const { unmutedUser, fromUser } = users;
const { peer: { domain: localPeerDomain } } = this;
// Check if room is federated
if (!FederatedRoom.isFederated(localPeerDomain, room)) { return users; }
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, room.federation._id);
const federatedUnmutedUser = FederatedUser.loadByFederationId(localPeerDomain, unmutedUser.federation._id);
const federatedUserWhoUnmuted = FederatedUser.loadByFederationId(localPeerDomain, fromUser.federation._id);
FederationEvents.userUnmuted(federatedRoom, federatedUnmutedUser, federatedUserWhoUnmuted, { skipPeers: [localPeerDomain] });
}
}
export default new PeerClient();

@ -0,0 +1,175 @@
import dns from 'dns';
import { Meteor } from 'meteor/meteor';
import { FederationDNSCache } from 'meteor/rocketchat:models';
import { logger } from './logger';
import peerHTTP from './peerHTTP';
import { updateStatus } from './settingsUpdater';
const dnsResolveSRV = Meteor.wrapAsync(dns.resolveSrv);
const dnsResolveTXT = Meteor.wrapAsync(dns.resolveTxt);
class PeerDNS {
constructor() {
this.config = {};
}
setConfig(config) {
// General
this.config = config;
// Setup HubPeer
const { hub: { url } } = config;
this.HubPeer = { url };
}
log(message) {
logger.dns.info(message);
}
// ########
//
// Register
//
// ########
register(peerConfig) {
const { uniqueId, domain, url, public_key } = peerConfig;
this.log(`Registering peer with domain ${ domain }...`);
// Attempt to register peer
try {
peerHTTP.request(this.HubPeer, 'POST', '/api/v1/peers', { uniqueId, domain, url, public_key }, { total: 5, stepSize: 1000, tryToUpdateDNS: false });
this.log('Peer registered!');
updateStatus('Running, registered to Hub');
return true;
} catch (err) {
this.log(err);
this.log('Could not register peer');
return false;
}
}
// #############
//
// Peer Handling
//
// #############
searchPeer(domain) {
this.log(`searchPeer: ${ domain }`);
let peer = FederationDNSCache.findOneByDomain(domain);
// Try to lookup at the DNS Cache
if (!peer) {
this.updatePeerDNS(domain);
peer = FederationDNSCache.findOneByDomain(domain);
}
return peer;
}
getPeerUsingDNS(domain) {
this.log(`getPeerUsingDNS: ${ domain }`);
// Try searching by DNS first
const srvEntries = dnsResolveSRV(`_rocketchat._tcp.${ domain }`);
const [srvEntry] = srvEntries;
// Get the public key from the TXT record
const txtRecords = dnsResolveTXT(domain);
let publicKey;
for (const txtRecord of txtRecords) {
const joinedTxtRecord = txtRecord.join('');
if (joinedTxtRecord.indexOf('rocketchat-public-key=') === 0) {
publicKey = joinedTxtRecord;
break;
}
}
if (!publicKey) {
throw new Meteor.Error('ENOTFOUND', 'Could not find public key entry on TXT records');
}
publicKey = publicKey.replace('rocketchat-public-key=', '');
const protocol = srvEntry.name === 'localhost' ? 'http' : 'https';
return {
domain,
url: `${ protocol }://${ srvEntry.name }:${ srvEntry.port }`,
public_key: publicKey,
};
}
getPeerUsingHub(domain) {
this.log(`getPeerUsingHub: ${ domain }`);
// If there is no DNS entry for that, get from the Hub
const { data: { peer } } = peerHTTP.simpleRequest(this.HubPeer, 'GET', `/api/v1/peers?search=${ domain }`);
return peer;
}
// ##############
//
// DNS Management
//
// ##############
updatePeerDNS(domain) {
this.log(`updatePeerDNS: ${ domain }`);
let peer;
try {
peer = this.getPeerUsingDNS(domain);
} catch (err) {
if (err.code !== 'ENOTFOUND') {
this.log(err);
throw new Error(`Error trying to fetch SRV DNS entries for ${ domain }`);
}
peer = this.getPeerUsingHub(domain);
}
this.updateDNSCache.call(this, peer);
return peer;
}
updateDNSEntry(peer) {
this.log('updateDNSEntry');
const { domain } = peer;
delete peer._id;
// Make sure public_key has no line breaks
peer.public_key = peer.public_key.replace(/\n|\r/g, '');
return FederationDNSCache.upsert({ domain }, peer);
}
updateDNSCache(peers) {
this.log('updateDNSCache');
peers = Array.isArray(peers) ? peers : [peers];
for (const peer of peers) {
this.updateDNSEntry.call(this, peer);
}
}
}
export default new PeerDNS();

@ -0,0 +1,126 @@
import { Meteor } from 'meteor/meteor';
import { HTTP } from 'meteor/http';
import { logger } from './logger';
import peerDNS from './peerDNS';
// Should skip the retry if the error is one of the below?
const errorsToSkipRetrying = [
'error-app-prevented-sending',
];
function skipRetryOnSpecificError(err) {
return errorsToSkipRetrying.includes(err && err.errorType);
}
// Delay method to wait a little bit before retrying
const delay = Meteor.wrapAsync(function(ms, callback) {
Meteor.setTimeout(function() {
callback(null);
}, ms);
});
function doSimpleRequest(peer, method, uri, body) {
this.log(`Request: ${ method } ${ uri }`);
const { url: serverBaseURL } = peer;
const url = `${ serverBaseURL }${ uri }`;
let data = null;
if (method === 'POST' || method === 'PUT') {
data = body;
}
this.log(`Sending request: ${ method } - ${ uri }`);
return HTTP.call(method, url, { data, timeout: 2000, headers: { 'x-federation-domain': this.config.peer.domain } });
}
//
// Actually does the request, handling retries and everything
function doRequest(peer, method, uri, body, retryInfo = {}) {
// Normalize retry info
retryInfo = {
total: retryInfo.total || 1,
stepSize: retryInfo.stepSize || 100,
stepMultiplier: retryInfo.stepMultiplier || 1,
tryToUpdateDNS: retryInfo.tryToUpdateDNS === undefined ? true : retryInfo.tryToUpdateDNS,
DNSUpdated: false,
};
for (let i = 0; i <= retryInfo.total; i++) {
try {
return doSimpleRequest.call(this, peer, method, uri, body);
} catch (err) {
try {
if (retryInfo.tryToUpdateDNS && !retryInfo.DNSUpdated) {
i--;
retryInfo.DNSUpdated = true;
this.log(`Trying to update local DNS cache for peer:${ peer.domain }`);
peer = peerDNS.updatePeerDNS(peer.domain);
continue;
}
} catch (err) {
if (err.response && err.response.statusCode === 404) {
throw new Meteor.Error('federation-peer-does-not-exist', 'Peer does not exist');
}
}
// Check if we need to skip due to specific error
if (skipRetryOnSpecificError(err && err.response && err.response.data)) {
this.log('Retry: skipping due to specific error');
throw err;
}
if (i === retryInfo.total - 1) {
// Throw the error, as we could not fulfill the request
this.log('Retry: could not fulfill the request');
throw err;
}
const timeToRetry = retryInfo.stepSize * (i + 1) * retryInfo.stepMultiplier;
this.log(`Trying again in ${ timeToRetry / 1000 }s: ${ method } - ${ uri }`);
// Otherwise, wait and try again
delay(timeToRetry);
}
}
}
class PeerHTTP {
constructor() {
this.config = {};
}
setConfig(config) {
// General
this.config = config;
}
log(message) {
logger.http.info(message);
}
//
// Direct request
simpleRequest(peer, method, uri, body) {
return doSimpleRequest.call(this, peer, method, uri, body);
}
//
// Request trying to find DNS entries
request(peer, method, uri, body, retryInfo = {}) {
return doRequest.call(this, peer, method, uri, body, retryInfo);
}
}
export default new PeerHTTP();

@ -0,0 +1,8 @@
import peerServer from './peerServer';
// Setup routes
import './routes/events';
import './routes/uploads';
import './routes/users';
export default peerServer;

@ -0,0 +1,388 @@
import { callbacks } from 'meteor/rocketchat:callbacks';
import { setReaction } from 'meteor/rocketchat:reactions';
import { addUserToRoom, removeUserFromRoom, deleteMessage } from 'meteor/rocketchat:lib';
import { Rooms, Subscriptions } from 'meteor/rocketchat:models';
import { FederatedMessage, FederatedRoom, FederatedUser } from '../federatedResources';
import { logger } from '../logger.js';
import peerClient from '../peerClient';
class PeerServer {
constructor() {
this.config = {};
this.enabled = false;
}
setConfig(config) {
// General
this.config = config;
}
log(message) {
logger.peerServer.info(message);
}
disable() {
this.log('Disabling...');
this.enabled = false;
}
enable() {
this.log('Enabling...');
this.enabled = true;
}
start() {
this.log('Routes are set');
}
handleDirectRoomCreatedEvent(e) {
this.log('handleDirectRoomCreatedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { room, owner, users } } = e;
// Load the federated room
const federatedRoom = new FederatedRoom(localPeerDomain, room, { owner });
// Set users
federatedRoom.setUsers(users);
// Create, if needed, all room's users
federatedRoom.createUsers();
// Then, create the room, if needed
federatedRoom.create();
}
handleRoomCreatedEvent(e) {
this.log('handleRoomCreatedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { room, owner, users } } = e;
// Load the federated room
const federatedRoom = new FederatedRoom(localPeerDomain, room, { owner });
// Set users
federatedRoom.setUsers(users);
// Create, if needed, all room's users
federatedRoom.createUsers();
// Then, create the room, if needed
federatedRoom.create();
}
handleUserJoinedEvent(e) {
this.log('handleUserJoinedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, user } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
// Create the user, if needed
const federatedUser = FederatedUser.loadOrCreate(localPeerDomain, user);
const localUser = federatedUser.create();
// Callback management
peerClient.addCallbackToSkip('afterAddedToRoom', federatedUser.getFederationId());
// Add the user to the room
addUserToRoom(federatedRoom.room._id, localUser, null, false);
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
}
handleUserAddedEvent(e) {
this.log('handleUserAddedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_inviter_id, user } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
// Load the inviter
const federatedInviter = FederatedUser.loadByFederationId(localPeerDomain, federated_inviter_id);
if (!federatedInviter) {
throw new Error('Inviting user does not exist');
}
const localInviter = federatedInviter.getLocalUser();
// Create the user, if needed
const federatedUser = FederatedUser.loadOrCreate(localPeerDomain, user);
const localUser = federatedUser.create();
// Callback management
peerClient.addCallbackToSkip('afterAddedToRoom', federatedUser.getFederationId());
// Add the user to the room
addUserToRoom(federatedRoom.room._id, localUser, localInviter, false);
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
}
handleUserLeftEvent(e) {
this.log('handleUserLeftEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_user_id } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
// Load the user who left
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
const localUser = federatedUser.getLocalUser();
// Callback management
peerClient.addCallbackToSkip('beforeLeaveRoom', federatedUser.getFederationId());
// Remove the user from the room
removeUserFromRoom(federatedRoom.room._id, localUser);
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
}
handleUserRemovedEvent(e) {
this.log('handleUserRemovedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_user_id, federated_removed_by_user_id } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
// Load the user who left
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
const localUser = federatedUser.getLocalUser();
// Load the user who removed
const federatedUserWhoRemoved = FederatedUser.loadByFederationId(localPeerDomain, federated_removed_by_user_id);
const localUserWhoRemoved = federatedUserWhoRemoved.getLocalUser();
// Callback management
peerClient.addCallbackToSkip('beforeRemoveFromRoom', federatedUser.getFederationId());
// Remove the user from the room
removeUserFromRoom(federatedRoom.room._id, localUser, { byUser: localUserWhoRemoved });
// Load federated users
federatedRoom.loadUsers();
// Refresh room's federation
federatedRoom.refreshFederation();
}
handleUserMutedEvent(e) {
this.log('handleUserMutedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_user_id } } = e;
// const { payload: { federated_room_id, federated_user_id, federated_muted_by_user_id } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
// Load the user who left
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
const localUser = federatedUser.getLocalUser();
// // Load the user who muted
// const federatedUserWhoMuted = FederatedUser.loadByFederationId(localPeerDomain, federated_muted_by_user_id);
// const localUserWhoMuted = federatedUserWhoMuted.getLocalUser();
// Mute user
Rooms.muteUsernameByRoomId(federatedRoom.room._id, localUser.username);
// TODO: should we create a message?
}
handleUserUnmutedEvent(e) {
this.log('handleUserUnmutedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_user_id } } = e;
// const { payload: { federated_room_id, federated_user_id, federated_unmuted_by_user_id } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
// Load the user who left
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
const localUser = federatedUser.getLocalUser();
// // Load the user who muted
// const federatedUserWhoUnmuted = FederatedUser.loadByFederationId(localPeerDomain, federated_unmuted_by_user_id);
// const localUserWhoUnmuted = federatedUserWhoUnmuted.getLocalUser();
// Unmute user
Rooms.unmuteUsernameByRoomId(federatedRoom.room._id, localUser.username);
// TODO: should we create a message?
}
handleMessageCreatedEvent(e) {
this.log('handleMessageCreatedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { message } } = e;
// Load the federated message
const federatedMessage = new FederatedMessage(localPeerDomain, message);
// Callback management
peerClient.addCallbackToSkip('afterSaveMessage', federatedMessage.getFederationId());
// Create the federated message
federatedMessage.create();
}
handleMessageUpdatedEvent(e) {
this.log('handleMessageUpdatedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { message, federated_user_id } } = e;
// Load the federated message
const federatedMessage = new FederatedMessage(localPeerDomain, message);
// Load the federated user
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
// Callback management
peerClient.addCallbackToSkip('afterSaveMessage', federatedMessage.getFederationId());
// Update the federated message
federatedMessage.update(federatedUser);
}
handleMessageDeletedEvent(e) {
this.log('handleMessageDeletedEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_message_id } } = e;
const federatedMessage = FederatedMessage.loadByFederationId(localPeerDomain, federated_message_id);
// Load the federated message
const localMessage = federatedMessage.getLocalMessage();
// Load the author
const localAuthor = federatedMessage.federatedAuthor.getLocalUser();
// Callback management
peerClient.addCallbackToSkip('afterDeleteMessage', federatedMessage.getFederationId());
// Create the federated message
deleteMessage(localMessage, localAuthor);
}
handleMessagesReadEvent(e) {
this.log('handleMessagesReadEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_user_id } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
peerClient.addCallbackToSkip('afterReadMessages', federatedRoom.getFederationId());
// Load the user who left
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
const localUser = federatedUser.getLocalUser();
// Mark the messages as read
// TODO: move below calls to an exported function
const userSubscription = Subscriptions.findOneByRoomIdAndUserId(federatedRoom.room._id, localUser._id, { fields: { ls: 1 } });
Subscriptions.setAsReadByRoomIdAndUserId(federatedRoom.room._id, localUser._id);
callbacks.run('afterReadMessages', federatedRoom.room._id, { userId: localUser._id, lastSeen: userSubscription.ls });
}
handleMessagesSetReactionEvent(e) {
this.log('handleMessagesSetReactionEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_message_id, federated_user_id, reaction, shouldReact } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
const localRoom = federatedRoom.getLocalRoom();
// Load the user who reacted
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
const localUser = federatedUser.getLocalUser();
// Load the message
const federatedMessage = FederatedMessage.loadByFederationId(localPeerDomain, federated_message_id);
const localMessage = federatedMessage.getLocalMessage();
// Callback management
peerClient.addCallbackToSkip('afterSetReaction', federatedMessage.getFederationId());
// Set message reaction
setReaction(localRoom, localUser, localMessage, reaction, shouldReact);
}
handleMessagesUnsetReactionEvent(e) {
this.log('handleMessagesUnsetReactionEvent');
const { peer: { domain: localPeerDomain } } = this.config;
const { payload: { federated_room_id, federated_message_id, federated_user_id, reaction, shouldReact } } = e;
// Load the federated room
const federatedRoom = FederatedRoom.loadByFederationId(localPeerDomain, federated_room_id);
const localRoom = federatedRoom.getLocalRoom();
// Load the user who reacted
const federatedUser = FederatedUser.loadByFederationId(localPeerDomain, federated_user_id);
const localUser = federatedUser.getLocalUser();
// Load the message
const federatedMessage = FederatedMessage.loadByFederationId(localPeerDomain, federated_message_id);
const localMessage = federatedMessage.getLocalMessage();
// Callback management
peerClient.addCallbackToSkip('afterUnsetReaction', federatedMessage.getFederationId());
// Unset message reaction
setReaction(localRoom, localUser, localMessage, reaction, shouldReact);
}
}
export default new PeerServer();

@ -0,0 +1,106 @@
import { API } from 'meteor/rocketchat:api';
import { FederationKeys } from 'meteor/rocketchat:models';
import { Federation } from '../../main';
import peerDNS from '../../peerDNS';
import peerServer from '../peerServer';
API.v1.addRoute('federation.events', { authRequired: false }, {
post() {
if (!peerServer.enabled) {
return API.v1.failure('Not found');
}
if (!this.bodyParams.payload) {
return API.v1.failure('Payload was not sent');
}
if (!this.request.headers['x-federation-domain']) {
return API.v1.failure('Cannot handle that request');
}
const remotePeerDomain = this.request.headers['x-federation-domain'];
const peer = peerDNS.searchPeer(remotePeerDomain);
if (!peer) {
return API.v1.failure('Could not find valid peer');
}
const payloadBuffer = Buffer.from(this.bodyParams.payload.data);
// Decrypt with the peer's public key
let payload = FederationKeys.loadKey(peer.public_key, 'public').decryptPublic(payloadBuffer);
// Decrypt with the local private key
payload = Federation.privateKey.decrypt(payload);
// Get the event
const { event: e } = JSON.parse(payload.toString());
if (!e) {
return API.v1.failure('Event was not sent');
}
peerServer.log(`Received event:${ e.t }`);
try {
switch (e.t) {
case 'drc':
peerServer.handleDirectRoomCreatedEvent(e);
break;
case 'roc':
peerServer.handleRoomCreatedEvent(e);
break;
case 'usj':
peerServer.handleUserJoinedEvent(e);
break;
case 'usa':
peerServer.handleUserAddedEvent(e);
break;
case 'usl':
peerServer.handleUserLeftEvent(e);
break;
case 'usr':
peerServer.handleUserRemovedEvent(e);
break;
case 'usm':
peerServer.handleUserMutedEvent(e);
break;
case 'usu':
peerServer.handleUserUnmutedEvent(e);
break;
case 'msc':
peerServer.handleMessageCreatedEvent(e);
break;
case 'msu':
peerServer.handleMessageUpdatedEvent(e);
break;
case 'msd':
peerServer.handleMessageDeletedEvent(e);
break;
case 'msr':
peerServer.handleMessagesReadEvent(e);
break;
case 'mrs':
peerServer.handleMessagesSetReactionEvent(e);
break;
case 'mru':
peerServer.handleMessagesUnsetReactionEvent(e);
break;
default:
throw new Error(`Invalid event:${ e.t }`);
}
peerServer.log('Success, responding...');
// Respond
return API.v1.success();
} catch (err) {
peerServer.log(`Error handling event:${ e.t } - ${ err.toString() }`);
return API.v1.failure(`Error handling event:${ e.t } - ${ err.toString() }`, err.error || 'unknown-error');
}
},
});

@ -0,0 +1,28 @@
import { Meteor } from 'meteor/meteor';
import { API } from 'meteor/rocketchat:api';
import { Uploads } from 'meteor/rocketchat:models';
import { FileUpload } from 'meteor/rocketchat:file-upload';
import peerServer from '../peerServer';
API.v1.addRoute('federation.uploads', { authRequired: false }, {
get() {
if (!peerServer.enabled) {
return API.v1.failure('Not found');
}
const { upload_id } = this.requestParams();
const upload = Uploads.findOneById(upload_id);
if (!upload) {
return API.v1.failure('There is no such file in this server');
}
const getFileBuffer = Meteor.wrapAsync(FileUpload.getBuffer, FileUpload);
const buffer = getFileBuffer(upload);
return API.v1.success({ upload, buffer });
},
});

@ -0,0 +1,49 @@
import { API } from 'meteor/rocketchat:api';
import { Users } from 'meteor/rocketchat:models';
import { FederatedUser } from '../../federatedResources';
import peerServer from '../peerServer';
API.v1.addRoute('federation.users', { authRequired: false }, {
get() {
if (!peerServer.enabled) {
return API.v1.failure('Not found');
}
const { peer: { domain: localPeerDomain } } = peerServer.config;
const { username, domain, emailOnly } = this.requestParams();
const email = `${ username }@${ domain }`;
peerServer.log(`[users] Trying to find user by username:${ username } and email:${ email }`);
const query = {
type: 'user',
};
if (emailOnly === 'true') {
query['emails.address'] = email;
} else {
query.$or = [
{ name: username },
{ username },
{ 'emails.address': email },
];
}
const users = Users.find(query, { fields: { services: 0, roles: 0 } }).fetch();
if (!users.length) {
return API.v1.failure('There is no such user in this server');
}
const federatedUsers = [];
for (const user of users) {
federatedUsers.push(new FederatedUser(localPeerDomain, user));
}
return API.v1.success({ federatedUsers });
},
});

@ -0,0 +1,17 @@
import { Settings } from 'meteor/rocketchat:models';
let nextStatus;
export function updateStatus(status) {
Settings.updateValueById('FEDERATION_Status', nextStatus || status);
nextStatus = null;
}
export function updateNextStatusTo(status) {
nextStatus = status;
}
export function updateEnabled(enabled) {
Settings.updateValueById('FEDERATION_Enabled', enabled);
}

@ -14,8 +14,10 @@ export const FileUpload = {
if (!Match.test(file.rid, String)) {
return false;
}
// livechat users can upload files but they don't have an userId
const user = file.userId ? Meteor.user() : null;
const user = file.userId ? Meteor.users.findOne(file.userId) : null;
const room = Rooms.findOneById(file.rid);
const directMessageAllow = settings.get('FileUpload_Enabled_Direct');
const fileUploadAllowed = settings.get('FileUpload_Enabled');

@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import fs from 'fs';
import stream from 'stream';
import streamBuffers from 'stream-buffers';
import mime from 'mime-type/with-db';
import Future from 'fibers/future';
import sharp from 'sharp';
@ -274,6 +275,22 @@ export const FileUpload = Object.assign(_FileUpload, {
res.end();
},
getBuffer(file, cb) {
const store = this.getStoreByName(file.store);
if (!store || !store.get) { cb(new Error('Store is invalid'), null); }
const buffer = new streamBuffers.WritableStreamBuffer({
initialSize: file.size,
});
buffer.on('finish', () => {
cb(null, buffer.getContents());
});
store.copy(file, buffer);
},
copy(file, targetFile) {
const store = this.getStoreByName(file.store);
const out = fs.createWriteStream(targetFile);

@ -1250,6 +1250,7 @@
"every_hour": "Once every hour",
"every_six_hours": "Once every six hours",
"every_day": "Once every day",
"Every_Workspace": "Every Workspace",
"Everyone_can_access_this_channel": "Everyone can access this channel",
"Example_s": "Example: <code class=\"inline\">%s</code>",
"Exclude_Botnames": "Exclude Bots",
@ -1270,6 +1271,20 @@
"Favorites": "Favorites",
"Feature_Depends_on_Livechat_Visitor_navigation_as_a_message_to_be_enabled": "This feature depends on \"Send Visitor Navigation History as a Message\" to be enabled.",
"Features_Enabled": "Features Enabled",
"FEDERATION_Enabled": "Attempt to integrate federation support. Changing this value requires restarting Rocket.Chat.",
"FEDERATION_Enabled_Alert": "Federation Support is a work in progress. Use on a production system is not recommended at this time.",
"FEDERATION_Discovery_Method": "Discovery Method",
"FEDERATION_Discovery_Method_Description": "You can use the hub or a SRV and a TXT entry on your DNS records.",
"FEDERATION_Domain": "Domain",
"FEDERATION_Domain_Description": "Add the domain that this server should be linked to - for example: @rocket.chat.",
"FEDERATION_Domain_Alert": "Do not change this after enabling the feature, we can't handle domain changes yet.",
"FEDERATION_Public_Key": "Public Key",
"FEDERATION_Public_Key_Description": "This is the key you need to share with your peers.",
"FEDERATION_Hub_URL": "Hub URL",
"FEDERATION_Hub_URL_Description": "Set the hub URL, for example: https://hub.rocket.chat. Ports are accepted as well.",
"FEDERATION_Unique_Id": "Unique ID",
"FEDERATION_Unique_Id_Description": "This is your federation unique ID, used to identify your peer on the mesh.",
"FEDERATION_Status": "Status",
"Field": "Field",
"Field_removed": "Field removed",
"Field_required": "Field required",
@ -1816,6 +1831,7 @@
"Loading_more_from_history": "Loading more from history",
"Loading_suggestion": "Loading suggestions",
"Local_Password": "Local Password",
"Local_Workspace": "Local Workspace",
"Localization": "Localization",
"Log_Exceptions_to_Channel_Description": "A channel that will receive all captured exceptions. Leave empty to ignore exceptions.",
"Log_Exceptions_to_Channel": "Log Exceptions to Channel",
@ -2692,6 +2708,7 @@
"The_emails_are_being_sent": "The emails are being sent.",
"The_field_is_required": "The field %s is required.",
"The_image_resize_will_not_work_because_we_can_not_detect_ImageMagick_or_GraphicsMagick_installed_in_your_server": "The image resize will not work because we can not detect ImageMagick or GraphicsMagick installed on your server.",
"The_peer__peer__does_not_exist": "The peer <em>__peer__</em> does not exist.",
"The_redirectUri_is_required": "The redirectUri is required",
"The_server_will_restart_in_s_seconds": "The server will restart in %s seconds",
"The_setting_s_is_configured_to_s_and_you_are_accessing_from_s": "The setting <strong>%s</strong> is configured to <strong>%s</strong> and you are accessing from <strong>%s</strong>!",
@ -2760,6 +2777,7 @@
"This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password": "This email has already been used and has not been verified. Please change your password.",
"This_is_a_desktop_notification": "This is a desktop notification",
"This_is_a_push_test_messsage": "This is a push test message",
"This_message_was_rejected_by__peer__peer": "This message was rejected by <em>__peer__</em> peer.",
"This_month": "This Month",
"This_room_has_been_archived_by__username_": "This room has been archived by __username__",
"This_room_has_been_unarchived_by__username_": "This room has been unarchived by __username__",

@ -14,6 +14,10 @@ export const addUserToRoom = function(rid, user, inviter, silenced) {
}
if (room.t === 'c' || room.t === 'p') {
// Add a new event, with an optional inviter
callbacks.run('beforeAddedToRoom', { user, inviter }, room);
// Keep the current event
callbacks.run('beforeJoinRoom', user, room);
}
@ -47,6 +51,10 @@ export const addUserToRoom = function(rid, user, inviter, silenced) {
if (room.t === 'c' || room.t === 'p') {
Meteor.defer(function() {
// Add a new event, with an optional inviter
callbacks.run('afterAddedToRoom', { user, inviter }, room);
// Keep the current event
callbacks.run('afterJoinRoom', user, room);
});
}

@ -7,7 +7,57 @@ import { Apps } from 'meteor/rocketchat:apps';
import _ from 'underscore';
import s from 'underscore.string';
export const createRoom = function(type, name, owner, members, readOnly, extraData = {}) {
function createDirectRoom(source, target, extraData, options) {
const rid = [source._id, target._id].sort().join('');
Rooms.upsert({ _id: rid }, {
$setOnInsert: Object.assign({
t: 'd',
usernames: [source.username, target.username],
msgs: 0,
ts: new Date(),
}, extraData),
});
Subscriptions.upsert({ rid, 'u._id': target._id }, {
$setOnInsert: Object.assign({
name: source.username,
t: 'd',
open: true,
alert: true,
unread: 0,
u: {
_id: target._id,
username: target.username,
},
}, options.subscriptionExtra),
});
Subscriptions.upsert({ rid, 'u._id': source._id }, {
$setOnInsert: Object.assign({
name: target.username,
t: 'd',
open: true,
alert: true,
unread: 0,
u: {
_id: source._id,
username: source.username,
},
}, options.subscriptionExtra),
});
return {
_id: rid,
t: 'd',
};
}
export const createRoom = function(type, name, owner, members, readOnly, extraData = {}, options = {}) {
if (type === 'd') {
return createDirectRoom(members[0], members[1], extraData, options);
}
name = s.trim(name);
owner = s.trim(owner);
members = [].concat(members);
@ -31,8 +81,15 @@ export const createRoom = function(type, name, owner, members, readOnly, extraDa
}
const now = new Date();
const validRoomNameOptions = {};
if (options.nameValidationRegex) {
validRoomNameOptions.nameValidationRegex = options.nameValidationRegex;
}
let room = Object.assign({
name: getValidRoomName(name),
name: getValidRoomName(name, null, validRoomNameOptions),
fname: name,
t: type,
msgs: 0,
@ -84,7 +141,9 @@ export const createRoom = function(type, name, owner, members, readOnly, extraDa
Rooms.muteUsernameByRoomId(room._id, username);
}
const extra = { open: true };
const extra = options.subscriptionExtra || {};
extra.open = true;
if (username === owner.username) {
extra.ls = now;

@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { Rooms, Messages, Subscriptions } from 'meteor/rocketchat:models';
import { callbacks } from 'meteor/rocketchat:callbacks';
export const removeUserFromRoom = function(rid, user) {
export const removeUserFromRoom = function(rid, user, options = {}) {
const room = Rooms.findOneById(rid);
if (room) {
@ -12,7 +12,13 @@ export const removeUserFromRoom = function(rid, user) {
if (subscription) {
const removedUser = user;
Messages.createUserLeaveWithRoomIdAndUser(rid, removedUser);
if (options.byUser) {
Messages.createUserRemovedWithRoomIdAndUser(rid, user, {
u: options.byUser,
});
} else {
Messages.createUserLeaveWithRoomIdAndUser(rid, removedUser);
}
}
if (room.t === 'l') {

File diff suppressed because it is too large Load Diff

@ -28,10 +28,10 @@ export default class {
return typeof this._useRealName === 'function' ? this._useRealName() : this._useRealName;
}
get userMentionRegex() {
return new RegExp(`(^|\\s|<p>|<br> ?)@(${ this.pattern })`, 'gm');
return new RegExp(`(^|\\s|<p>|<br> ?)@(${ this.pattern }(@(${ this.pattern }))?)`, 'gm');
}
get channelMentionRegex() {
return new RegExp(`(^|\\s|<p>)#(${ this.pattern })`, 'gm');
return new RegExp(`(^|\\s|<p>)#(${ this.pattern }(@(${ this.pattern }))?)`, 'gm');
}
replaceUsers(str, message, me) {
return str.replace(this.userMentionRegex, (match, prefix, username) => {
@ -40,9 +40,11 @@ export default class {
}
const mentionObj = message.mentions && message.mentions.find((m) => m.username === username);
if (message.temp == null && mentionObj == null) {
return match;
}
const name = this.useRealName && mentionObj && s.escapeHTML(mentionObj.name);
return `${ prefix }<a class="mention-link ${ username === me ? 'mention-link-me' : '' }" data-username="${ username }" title="${ name ? username : '' }">${ name || `@${ username }` }</a>`;

@ -35,6 +35,9 @@ import ReadReceipts from './models/ReadReceipts';
export { AppsLogsModel } from './models/apps-logs-model';
export { AppsPersistenceModel } from './models/apps-persistence-model';
export { AppsModel } from './models/apps-model';
export { FederationDNSCache } from './models/FederationDNSCache';
export { FederationEvents } from './models/FederationEvents';
export { FederationKeys } from './models/FederationKeys';
export {
Base,

@ -0,0 +1,13 @@
import { Base } from './_Base';
class FederationDNSCacheModel extends Base {
constructor() {
super('federation_dns_cache');
}
findOneByDomain(domain) {
return this.findOne({ domain });
}
}
export const FederationDNSCache = new FederationDNSCacheModel();

@ -0,0 +1,255 @@
import { Base } from './_Base';
const normalizePeers = (basePeers, options) => {
const { peers: sentPeers, skipPeers } = options;
let peers = sentPeers || basePeers || [];
if (skipPeers) {
peers = peers.filter((p) => skipPeers.indexOf(p) === -1);
}
return peers;
};
//
// We should create a time to live index in this table to remove fulfilled events
//
class FederationEventsModel extends Base {
constructor() {
super('federation_events');
}
// Sometimes events errored but the error is final
setEventAsErrored(e, error, fulfilled = false) {
this.update({ _id: e._id }, {
$set: {
fulfilled,
lastAttemptAt: new Date(),
error,
},
});
}
setEventAsFullfilled(e) {
this.update({ _id: e._id }, {
$set: { fulfilled: true },
$unset: { error: 1 },
});
}
createEvent(type, payload, peer) {
const record = {
t: type,
ts: new Date(),
fulfilled: false,
payload,
peer,
};
record._id = this.insert(record);
this.emit('createEvent', record);
return record;
}
createEventForPeers(type, payload, peers) {
const records = [];
for (const peer of peers) {
const record = this.createEvent(type, payload, peer);
records.push(record);
}
return records;
}
// Create a `directRoomCreated(drc)` event
directRoomCreated(federatedRoom, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
room: federatedRoom.getRoom(),
owner: federatedRoom.getOwner(),
users: federatedRoom.getUsers(),
};
return this.createEventForPeers('drc', payload, peers);
}
// Create a `roomCreated(roc)` event
roomCreated(federatedRoom, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
room: federatedRoom.getRoom(),
owner: federatedRoom.getOwner(),
users: federatedRoom.getUsers(),
};
return this.createEventForPeers('roc', payload, peers);
}
// Create a `userJoined(usj)` event
userJoined(federatedRoom, federatedUser, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
user: federatedUser.getUser(),
};
return this.createEventForPeers('usj', payload, peers);
}
// Create a `userAdded(usa)` event
userAdded(federatedRoom, federatedUser, federatedInviter, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_inviter_id: federatedInviter.getFederationId(),
user: federatedUser.getUser(),
};
return this.createEventForPeers('usa', payload, peers);
}
// Create a `userLeft(usl)` event
userLeft(federatedRoom, federatedUser, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_user_id: federatedUser.getFederationId(),
};
return this.createEventForPeers('usl', payload, peers);
}
// Create a `userRemoved(usr)` event
userRemoved(federatedRoom, federatedUser, federatedRemovedByUser, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_user_id: federatedUser.getFederationId(),
federated_removed_by_user_id: federatedRemovedByUser.getFederationId(),
};
return this.createEventForPeers('usr', payload, peers);
}
// Create a `userMuted(usm)` event
userMuted(federatedRoom, federatedUser, federatedMutedByUser, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_user_id: federatedUser.getFederationId(),
federated_muted_by_user_id: federatedMutedByUser.getFederationId(),
};
return this.createEventForPeers('usm', payload, peers);
}
// Create a `userUnmuted(usu)` event
userUnmuted(federatedRoom, federatedUser, federatedUnmutedByUser, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_user_id: federatedUser.getFederationId(),
federated_unmuted_by_user_id: federatedUnmutedByUser.getFederationId(),
};
return this.createEventForPeers('usu', payload, peers);
}
// Create a `messageCreated(msc)` event
messageCreated(federatedRoom, federatedMessage, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
message: federatedMessage.getMessage(),
};
return this.createEventForPeers('msc', payload, peers);
}
// Create a `messageUpdated(msu)` event
messageUpdated(federatedRoom, federatedMessage, federatedUser, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
message: federatedMessage.getMessage(),
federated_user_id: federatedUser.getFederationId(),
};
return this.createEventForPeers('msu', payload, peers);
}
// Create a `deleteMessage(msd)` event
messageDeleted(federatedRoom, federatedMessage, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_message_id: federatedMessage.getFederationId(),
};
return this.createEventForPeers('msd', payload, peers);
}
// Create a `messagesRead(msr)` event
messagesRead(federatedRoom, federatedUser, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_user_id: federatedUser.getFederationId(),
};
return this.createEventForPeers('msr', payload, peers);
}
// Create a `messagesSetReaction(mrs)` event
messagesSetReaction(federatedRoom, federatedMessage, federatedUser, reaction, shouldReact, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_message_id: federatedMessage.getFederationId(),
federated_user_id: federatedUser.getFederationId(),
reaction,
shouldReact,
};
return this.createEventForPeers('mrs', payload, peers);
}
// Create a `messagesUnsetReaction(mru)` event
messagesUnsetReaction(federatedRoom, federatedMessage, federatedUser, reaction, shouldReact, options = {}) {
const peers = normalizePeers(federatedRoom.getPeers(), options);
const payload = {
federated_room_id: federatedRoom.getFederationId(),
federated_message_id: federatedMessage.getFederationId(),
federated_user_id: federatedUser.getFederationId(),
reaction,
shouldReact,
};
return this.createEventForPeers('mru', payload, peers);
}
// Get all unfulfilled events
getUnfulfilled() {
return this.find({ fulfilled: false }, { sort: { ts: 1 } }).fetch();
}
}
export const FederationEvents = new FederationEventsModel();

@ -0,0 +1,69 @@
import NodeRSA from 'node-rsa';
import uuid from 'uuid/v4';
import { Base } from './_Base';
class FederationKeysModel extends Base {
constructor() {
super('federation_keys');
}
getKey(type) {
const keyResource = this.findOne({ type });
if (!keyResource) { return null; }
return keyResource.key;
}
loadKey(keyData, type) {
return new NodeRSA(keyData, `pkcs8-${ type }-pem`);
}
generateKeys() {
const key = new NodeRSA({ b: 512 });
key.generateKeyPair();
this.update({ type: 'private' }, { type: 'private', key: key.exportKey('pkcs8-private-pem').replace(/\n|\r/g, '') }, { upsert: true });
this.update({ type: 'public' }, { type: 'public', key: key.exportKey('pkcs8-public-pem').replace(/\n|\r/g, '') }, { upsert: true });
return {
privateKey: this.getPrivateKey(),
publicKey: this.getPublicKey(),
};
}
generateUniqueId() {
const uniqueId = uuid();
this.update({ type: 'unique' }, { type: 'unique', key: uniqueId }, { upsert: true });
}
getUniqueId() {
return (this.findOne({ type: 'unique' }) || {}).key;
}
getPrivateKey() {
const keyData = this.getKey('private');
return keyData && this.loadKey(keyData, 'private');
}
getPrivateKeyString() {
return this.getKey('private');
}
getPublicKey() {
const keyData = this.getKey('public');
return keyData && this.loadKey(keyData, 'public');
}
getPublicKeyString() {
return this.getKey('public');
}
}
export const FederationKeys = new FederationKeysModel();

@ -813,6 +813,16 @@ export class Messages extends Base {
return this.createWithTypeRoomIdMessageAndUser('subscription-role-removed', roomId, message, user, extraData);
}
createRejectedMessageByPeer(roomId, user, extraData) {
const message = user.username;
return this.createWithTypeRoomIdMessageAndUser('rejected-message-by-peer', roomId, message, user, extraData);
}
createPeerDoesNotExist(roomId, user, extraData) {
const message = user.username;
return this.createWithTypeRoomIdMessageAndUser('peer-does-not-exist', roomId, message, user, extraData);
}
// REMOVE
removeById(_id) {
const query = { _id };

@ -487,7 +487,7 @@ export class Users extends Base {
return this.find(query, options);
}
findByActiveUsersExcept(searchTerm, exceptions, options) {
findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields) {
if (exceptions == null) { exceptions = []; }
if (options == null) { options = {}; }
if (!_.isArray(exceptions)) {
@ -496,7 +496,9 @@ export class Users extends Base {
const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i');
const orStmt = _.reduce(this.settings.get('Accounts_SearchFields').trim().split(','), function(acc, el) {
const searchFields = forcedSearchFields || this.settings.get('Accounts_SearchFields').trim().split(',');
const orStmt = _.reduce(searchFields, function(acc, el) {
acc.push({ [el.trim()]: termRegex });
return acc;
}, []);

@ -1 +1 @@
import './setReaction';
export { setReaction } from './setReaction';

@ -16,88 +16,95 @@ const removeUserReaction = (message, reaction, username) => {
return message;
};
Meteor.methods({
setReaction(reaction, messageId, shouldReact) {
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' });
}
const message = Messages.findOneById(messageId);
export function setReaction(room, user, message, reaction, shouldReact) {
reaction = `:${ reaction.replace(/:/g, '') }:`;
if (!message) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
}
if (!emoji.list[reaction] && EmojiCustom.findByNameOrAlias(reaction).count() === 0) {
throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { method: 'setReaction' });
}
const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId());
if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !room.reactWhenReadOnly) {
Notifications.notifyUser(Meteor.userId(), 'message', {
_id: Random.id(),
rid: room._id,
ts: new Date(),
msg: TAPi18n.__('You_have_been_muted', {}, user.language),
});
return false;
} else if (!Subscriptions.findOne({ rid: message.rid })) {
return false;
}
if (!room) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
}
const userAlreadyReacted = Boolean(message.reactions) && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.indexOf(user.username) !== -1;
// When shouldReact was not informed, toggle the reaction.
if (shouldReact === undefined) {
shouldReact = !userAlreadyReacted;
}
reaction = `:${ reaction.replace(/:/g, '') }:`;
if (userAlreadyReacted === shouldReact) {
return;
}
if (userAlreadyReacted) {
removeUserReaction(message, reaction, user.username);
if (!emoji.list[reaction] && EmojiCustom.findByNameOrAlias(reaction).count() === 0) {
throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { method: 'setReaction' });
if (_.isEmpty(message.reactions)) {
delete message.reactions;
if (isTheLastMessage(room, message)) {
Rooms.unsetReactionsInLastMessage(room._id);
}
Messages.unsetReactions(message._id);
callbacks.run('unsetReaction', message._id, reaction);
callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact });
} else {
if (isTheLastMessage(room, message)) {
Rooms.setReactionsInLastMessage(room._id, message);
}
Messages.setReactions(message._id, message.reactions);
callbacks.run('setReaction', message._id, reaction);
callbacks.run('afterSetReaction', message, { user, reaction, shouldReact });
}
} else {
if (!message.reactions) {
message.reactions = {};
}
if (!message.reactions[reaction]) {
message.reactions[reaction] = {
usernames: [],
};
}
message.reactions[reaction].usernames.push(user.username);
if (isTheLastMessage(room, message)) {
Rooms.setReactionsInLastMessage(room._id, message);
}
Messages.setReactions(message._id, message.reactions);
callbacks.run('setReaction', message._id, reaction);
callbacks.run('afterSetReaction', message, { user, reaction, shouldReact });
}
msgStream.emit(message.rid, message);
}
Meteor.methods({
setReaction(reaction, messageId, shouldReact) {
const user = Meteor.user();
if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !room.reactWhenReadOnly) {
Notifications.notifyUser(Meteor.userId(), 'message', {
_id: Random.id(),
rid: room._id,
ts: new Date(),
msg: TAPi18n.__('You_have_been_muted', {}, user.language),
});
return false;
} else if (!Subscriptions.findOne({ rid: message.rid })) {
return false;
}
const message = Messages.findOneById(messageId);
const userAlreadyReacted = Boolean(message.reactions) && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.indexOf(user.username) !== -1;
// When shouldReact was not informed, toggle the reaction.
if (shouldReact === undefined) {
shouldReact = !userAlreadyReacted;
const room = Meteor.call('canAccessRoom', message.rid, Meteor.userId());
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' });
}
if (userAlreadyReacted === shouldReact) {
return;
if (!message) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
}
if (userAlreadyReacted) {
removeUserReaction(message, reaction, user.username);
if (_.isEmpty(message.reactions)) {
delete message.reactions;
if (isTheLastMessage(room, message)) {
Rooms.unsetReactionsInLastMessage(room._id);
}
Messages.unsetReactions(messageId);
callbacks.run('unsetReaction', messageId, reaction);
} else {
if (isTheLastMessage(room, message)) {
Rooms.setReactionsInLastMessage(room._id, message);
}
Messages.setReactions(messageId, message.reactions);
callbacks.run('setReaction', messageId, reaction);
}
} else {
if (!message.reactions) {
message.reactions = {};
}
if (!message.reactions[reaction]) {
message.reactions[reaction] = {
usernames: [],
};
}
message.reactions[reaction].usernames.push(user.username);
if (isTheLastMessage(room, message)) {
Rooms.setReactionsInLastMessage(room._id, message);
}
Messages.setReactions(messageId, message.reactions);
callbacks.run('setReaction', messageId, reaction);
if (!room) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
}
msgStream.emit(message.rid, message);
setReaction(room, user, message, reaction, shouldReact);
return;
},

@ -17,7 +17,8 @@ function Invite(command, params, item) {
if (command !== 'invite' || !Match.test(params, String)) {
return;
}
const usernames = params.split(/[\s,]/).map((username) => username.replace(/^@/, '')).filter((a) => a !== '');
const usernames = params.split(/[\s,]/).map((username) => username.replace(/(^@)|( @)/, '')).filter((a) => a !== '');
if (usernames.length === 0) {
return;
}

@ -3,14 +3,25 @@
{{> header sectionName="Directory" hideHelp=true fullpage=true}}
<div class="rc-table-content">
{{>tabs tabs=tabsData}}
<div class="rc-input rc-input--small rc-directory-search">
<label class="rc-input__label">
<div class="rc-input__wrapper">
{{> icon icon="magnifier" block="rc-input__icon" }}
<input type="text" class="rc-input__element rc-input__element--small js-search" name="message-search" id="message-search" placeholder={{#if $eq searchType 'channels'}}{{_ "Search_Channels"}}{{/if}}{{#if $eq searchType 'users'}}{{_ "Search_Users"}}{{/if}} autocomplete="off">
</div>
</label>
<div class="rc-directory-fields">
{{#if $eq searchType 'users'}}
{{#if federationEnabled}}
<label class="rc-select rc-directory-dropdown rc-directory-search">
<select class="rc-select__element js-setting-data js-workspace" name="search-type">
<option class="rc-select__option" value="local" selected="true">{{_ "Local_Workspace"}}</option>
<option class="rc-select__option" value="all">{{_ "Every_Workspace"}}</option>
</select>
{{> icon block="rc-select__arrow" icon="arrow-down" }}
</label>
{{/if}}
{{/if}}
<div class="rc-input rc-input--small rc-directory-search">
{{> icon icon="magnifier" block="rc-input__icon" }}
<input type="text" class="rc-input__element rc-input__element--small js-search" name="message-search" id="message-search" placeholder={{#if $eq searchType 'channels'}}{{_ "Search_Channels"}}{{/if}}{{#if $eq searchType 'users'}}{{_ "Search_Users"}}{{/if}} autocomplete="off">
</div>
</div>
{{#if $eq searchType 'channels'}}
{{#table fixed='true' onItemClick=onTableItemClick onScroll=onTableScroll onResize=onTableResize onSort=onTableSort}}
<thead>
@ -80,6 +91,14 @@
<th class="js-sort {{#if searchSortBy 'username'}}is-sorting{{/if}}" data-sort="username">
<div class="table-fake-th"><span>{{_ "Username"}}</span> {{> icon icon=(sortIcon 'username') }}</div>
</th>
<th class="js-sort {{#if searchSortBy 'email'}}is-sorting{{/if}}" data-sort="email">
<div class="table-fake-th"><span>{{_ "Email"}}</span> {{> icon icon=(sortIcon 'email') }}</div>
</th>
{{#if $eq searchWorkspace 'all'}}
<th class="js-sort {{#if searchSortBy 'domain'}}is-sorting{{/if}}" data-sort="domain">
<div class="table-fake-th"><span>{{_ "Domain"}}</span> {{> icon icon=(sortIcon 'domain') }}</div>
</th>
{{/if}}
<th class="js-sort table-column-date {{#if searchSortBy 'createdAt'}}is-sorting{{/if}}" data-sort="createdAt">
<div class="table-fake-th"><span>{{_ "Created_at"}}</span> {{> icon icon=(sortIcon 'createdAt') }}</div>
</th>
@ -99,6 +118,10 @@
</div>
</td>
<td>{{username}}</td>
<td>{{email}}</td>
{{#if $eq searchWorkspace 'all'}}
<td>{{domain}}</td>
{{/if}}
<td>{{createdAt}}</td>
</tr>
{{else}}

@ -27,7 +27,10 @@ function directorySearch(config, cb) {
return {
name: result.name,
username: result.username,
// If there is no email address (probably only rocket.cat) show the username)
email: (result.emails && result.emails[0] && result.emails[0].address) || result.username,
createdAt: timeAgo(result.createdAt, t),
domain: result.federation && result.federation.peer,
};
}
return null;
@ -36,9 +39,15 @@ function directorySearch(config, cb) {
}
Template.directory.helpers({
federationEnabled() {
return settings.get('FEDERATION_Enabled');
},
searchText() {
return Template.instance().searchText.get();
},
searchWorkspace() {
return Template.instance().searchWorkspace.get();
},
showLastMessage() {
return settings.get('Store_Last_Message');
},
@ -107,18 +116,35 @@ Template.directory.helpers({
};
},
onTableItemClick() {
const { searchType } = Template.instance();
const instance = Template.instance();
const { searchType } = instance;
let type;
let routeConfig;
return function(item) {
if (searchType.get() === 'channels') {
type = 'c';
routeConfig = { name: item.name };
// This means we need to add this user locally first
if (item.remoteOnly) {
Meteor.call('federationAddUser', item.email, item.domain, (error, federatedUser) => {
if (!federatedUser) { return; }
// Reload
instance.end.set(false);
// directorySearch.call(instance);
roomTypes.openRouteLink('d', { name: item.username });
});
} else {
type = 'd';
routeConfig = { name: item.username };
if (searchType.get() === 'channels') {
type = 'c';
routeConfig = { name: item.name };
} else {
type = 'd';
routeConfig = { name: item.username };
}
roomTypes.openRouteLink(type, routeConfig);
}
roomTypes.openRouteLink(type, routeConfig);
};
},
isLoading() {
@ -170,36 +196,84 @@ Template.directory.events({
t.page.set(0);
t.searchText.set(e.currentTarget.value);
}, 300),
'change .js-workspace': (e, t) => {
t.end.set(false);
t.sortDirection.set('asc');
t.page.set(0);
t.searchWorkspace.set(e.target.value);
},
});
Template.directory.onRendered(function() {
function setResults(result) {
if (!Array.isArray(result)) {
result = [];
}
if (this.page.get() > 0) {
return this.results.set([...this.results.get(), ...result]);
}
return this.results.set(result);
}
Tracker.autorun(() => {
const searchConfig = {
text: this.searchText.get(),
workspace: this.searchWorkspace.get(),
type: this.searchType.get(),
sortBy: this.searchSortBy.get(),
sortDirection: this.sortDirection.get(),
limit: this.limit.get(),
page: this.page.get(),
};
if (this.end.get() || this.loading) {
return;
}
this.loading = true;
this.isLoading.set(true);
directorySearch(searchConfig, (result) => {
this.loading = false;
this.isLoading.set(false);
this.end.set(!result);
if (!Array.isArray(result)) {
result = [];
}
// If there is no result, searching every workspace and
// the search text is an email address, try to find a federated user
if (this.searchWorkspace.get() === 'all' && this.searchText.get().indexOf('@') !== -1) {
const email = this.searchText.get();
Meteor.call('federationSearchUsers', email, (error, federatedUsers) => {
if (!federatedUsers) { return; }
result = result || [];
for (const federatedUser of federatedUsers) {
const { user } = federatedUser;
if (this.page.get() > 0) {
return this.results.set([...this.results.get(), ...result]);
const exists = result.findIndex((e) => e.domain === user.federation.peer && e.username === user.username) !== -1;
if (exists) { continue; }
// Add the federated user to the results
result.unshift({
remoteOnly: true,
name: user.name,
username: user.username,
email: user.emails && user.emails[0] && user.emails[0].address,
createdAt: timeAgo(user.createdAt, t),
domain: user.federation.peer,
});
}
setResults.call(this, result);
});
}
return this.results.set(result);
setResults.call(this, result);
});
});
});
@ -215,6 +289,7 @@ Template.directory.onCreated(function() {
this.sortDirection = new ReactiveVar('asc');
}
this.searchText = new ReactiveVar('');
this.searchWorkspace = new ReactiveVar('local');
this.limit = new ReactiveVar(0);
this.page = new ReactiveVar(0);
this.end = new ReactiveVar(false);

@ -3,7 +3,7 @@ import limax from 'limax';
import { settings } from 'meteor/rocketchat:settings';
import { Rooms } from 'meteor/rocketchat:models';
export const getValidRoomName = (displayName, rid = '') => {
export const getValidRoomName = (displayName, rid = '', options = {}) => {
let slugifiedName = displayName;
if (settings.get('UI_Allow_room_names_with_special_chars')) {
@ -19,11 +19,17 @@ export const getValidRoomName = (displayName, rid = '') => {
}
let nameValidation;
try {
nameValidation = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`);
} catch (error) {
nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$');
if (options.nameValidationRegex) {
nameValidation = new RegExp(options.nameValidationRegex);
} else {
try {
nameValidation = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`);
} catch (error) {
nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$');
}
}
if (!nameValidation.test(slugifiedName)) {
throw new Meteor.Error('error-invalid-room-name', `${ slugifiedName } is not a valid room name.`, {
function: 'RocketChat.getValidRoomName',

@ -6,8 +6,27 @@
padding: 1.25rem 2rem;
&-fields {
display: flex;
flex-direction: row;
height: 89px;
margin-right: -5px;
margin-left: -5px;
padding: 10px 0;
}
&-dropdown {
height: 48px;
}
&-search {
width: 100%;
height: 48px;
margin-right: 5px;
margin-left: 5px;
& .rc-icon {
width: 0.875rem;

@ -4,6 +4,8 @@ import { hasPermission } from 'meteor/rocketchat:authorization';
import { Rooms, Users } from 'meteor/rocketchat:models';
import s from 'underscore.string';
import { Federation } from 'meteor/rocketchat:federation';
const sortChannels = function(field, direction) {
switch (field) {
case 'createdAt':
@ -27,7 +29,7 @@ const sortUsers = function(field, direction) {
};
Meteor.methods({
browseChannels({ text = '', type = 'channels', sortBy = 'name', sortDirection = 'asc', page, offset, limit = 10 }) {
browseChannels({ text = '', workspace = '', type = 'channels', sortBy = 'name', sortDirection = 'asc', page, offset, limit = 10 }) {
const regex = new RegExp(s.trim(s.escapeRegExp(text)), 'i');
if (!['channels', 'users'].includes(type)) {
@ -56,27 +58,30 @@ Meteor.methods({
};
const user = Meteor.user();
if (type === 'channels') {
if (type === 'channels') {
const sort = sortChannels(sortBy, sortDirection);
if (!hasPermission(user._id, 'view-c-room')) {
return;
}
const result = Rooms.findByNameAndType(regex, 'c', {
...options,
sort,
fields: {
description: 1,
topic: 1,
name: 1,
lastMessage: 1,
ts: 1,
archived: 1,
usersCount: 1,
},
});
return {
results: Rooms.findByNameAndType(regex, 'c', {
...options,
sort,
fields: {
description: 1,
topic: 1,
name: 1,
lastMessage: 1,
ts: 1,
archived: 1,
usersCount: 1,
},
}).fetch(),
total: Rooms.findByNameAndType(regex, 'c').count(),
total: result.count(), // count ignores the `skip` and `limit` options
results: result.fetch(),
};
}
@ -84,19 +89,49 @@ Meteor.methods({
if (!hasPermission(user._id, 'view-outside-room') || !hasPermission(user._id, 'view-d-room')) {
return;
}
let exceptions = [user.username];
// Get exceptions
if (type === 'users' && workspace === 'all') {
const nonFederatedUsers = Users.find({
$or: [
{ federation: { $exists: false } },
{ 'federation.peer': Federation.localIdentifier },
],
}, { fields: { username: 1 } }).map((u) => u.username);
exceptions = exceptions.concat(nonFederatedUsers);
} else if (type === 'users' && workspace === 'local') {
const federatedUsers = Users.find({
$and: [
{ federation: { $exists: true } },
{ 'federation.peer': { $ne: Federation.localIdentifier } },
],
}, { fields: { username: 1 } }).map((u) => u.username);
exceptions = exceptions.concat(federatedUsers);
}
const sort = sortUsers(sortBy, sortDirection);
const forcedSearchFields = workspace === 'all' && ['username', 'name', 'emails.address'];
const result = Users.findByActiveUsersExcept(text, exceptions, {
...options,
sort,
fields: {
username: 1,
name: 1,
createdAt: 1,
emails: 1,
federation: 1,
},
}, forcedSearchFields);
return {
results: Users.findByActiveUsersExcept(text, [user.username], {
...options,
sort,
fields: {
username: 1,
name: 1,
createdAt: 1,
emails: 1,
},
}).fetch(),
total: Users.findByActiveUsersExcept(text, [user.username]).count(),
total: result.count(), // count ignores the `skip` and `limit` options
results: result.fetch(),
};
},
});

@ -5,6 +5,7 @@ import { hasPermission } from 'meteor/rocketchat:authorization';
import { Users, Rooms, Subscriptions } from 'meteor/rocketchat:models';
import { getDefaultSubscriptionPref } from 'meteor/rocketchat:utils';
import { RateLimiter } from 'meteor/rocketchat:lib';
import { callbacks } from 'meteor/rocketchat:callbacks';
Meteor.methods({
createDirectMessage(username) {
@ -55,7 +56,7 @@ Meteor.methods({
const now = new Date();
// Make sure we have a room
Rooms.upsert({
const roomUpsertResult = Rooms.upsert({
_id: rid,
}, {
$set: {
@ -129,6 +130,13 @@ Meteor.methods({
},
});
// If the room is new, run a callback
if (roomUpsertResult.insertedId) {
const insertedRoom = Rooms.findOneById(rid);
callbacks.run('afterCreateDirectRoom', insertedRoom, { from: me, to });
}
return {
rid,
};

@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { Rooms, Subscriptions, Users, Messages } from 'meteor/rocketchat:models';
import { hasPermission } from 'meteor/rocketchat:authorization';
import { callbacks } from 'meteor/rocketchat:callbacks';
Meteor.methods({
muteUserInRoom(data) {
@ -48,10 +49,12 @@ Meteor.methods({
const mutedUser = Users.findOneByUsername(data.username);
Rooms.muteUsernameByRoomId(data.rid, mutedUser.username);
const fromUser = Users.findOneById(fromId);
callbacks.run('beforeMuteUser', { mutedUser, fromUser }, room);
Rooms.muteUsernameByRoomId(data.rid, mutedUser.username);
Messages.createUserMutedWithRoomIdAndUser(data.rid, mutedUser, {
u: {
_id: fromUser._id,
@ -59,6 +62,10 @@ Meteor.methods({
},
});
Meteor.defer(function() {
callbacks.run('afterMuteUser', { mutedUser, fromUser }, room);
});
return true;
},
});

@ -1,6 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { ReadReceipt } from '../../imports/message-read-receipt/server/lib/ReadReceipt';
import { callbacks } from 'meteor/rocketchat:callbacks';
import { Subscriptions } from 'meteor/rocketchat:models';
Meteor.methods({
@ -15,13 +15,14 @@ Meteor.methods({
});
}
// this prevents cache from updating object reference/pointer
const userSubscription = Object.assign({}, Subscriptions.findOneByRoomIdAndUserId(rid, userId));
callbacks.run('beforeReadMessages', rid, userId);
// TODO: move this calls to an exported function
const userSubscription = Subscriptions.findOneByRoomIdAndUserId(rid, userId, { fields: { ls: 1 } });
Subscriptions.setAsReadByRoomIdAndUserId(rid, userId);
Meteor.defer(() => {
ReadReceipt.markMessagesAsRead(rid, userId, userSubscription.ls);
callbacks.run('afterReadMessages', rid, { userId, lastSeen: userSubscription.ls });
});
},
});

@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { hasPermission, hasRole, getUsersInRole, removeUserFromRoles } from 'meteor/rocketchat:authorization';
import { Users, Subscriptions, Rooms, Messages } from 'meteor/rocketchat:models';
import { callbacks } from 'meteor/rocketchat:callbacks';
Meteor.methods({
removeUserFromRoom(data) {
@ -34,6 +35,8 @@ Meteor.methods({
const removedUser = Users.findOneByUsername(data.username);
const fromUser = Users.findOneById(fromId);
const subscription = Subscriptions.findOneByRoomIdAndUserId(data.rid, removedUser._id, { fields: { _id: 1 } });
if (!subscription) {
throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', {
@ -51,14 +54,14 @@ Meteor.methods({
}
}
callbacks.run('beforeRemoveFromRoom', { removedUser, userWhoRemoved: fromUser }, room);
Subscriptions.removeByRoomIdAndUserId(data.rid, removedUser._id);
if (['c', 'p'].includes(room.t) === true) {
removeUserFromRoles(removedUser._id, ['moderator', 'owner'], data.rid);
}
const fromUser = Users.findOneById(fromId);
Messages.createUserRemovedWithRoomIdAndUser(data.rid, removedUser, {
u: {
_id: fromUser._id,
@ -66,6 +69,10 @@ Meteor.methods({
},
});
Meteor.defer(function() {
callbacks.run('afterRemoveFromRoom', { removedUser, userWhoRemoved: fromUser }, room);
});
return true;
},
});

@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { hasPermission } from 'meteor/rocketchat:authorization';
import { Users, Subscriptions, Rooms, Messages } from 'meteor/rocketchat:models';
import { callbacks } from 'meteor/rocketchat:callbacks';
Meteor.methods({
unmuteUserInRoom(data) {
@ -42,10 +43,12 @@ Meteor.methods({
const unmutedUser = Users.findOneByUsername(data.username);
Rooms.unmuteUsernameByRoomId(data.rid, unmutedUser.username);
const fromUser = Users.findOneById(fromId);
callbacks.run('beforeUnmuteUser', { unmutedUser, fromUser }, room);
Rooms.unmuteUsernameByRoomId(data.rid, unmutedUser.username);
Messages.createUserUnmutedWithRoomIdAndUser(data.rid, unmutedUser, {
u: {
_id: fromUser._id,
@ -53,6 +56,10 @@ Meteor.methods({
},
});
Meteor.defer(function() {
callbacks.run('afterUnmuteUser', { unmutedUser, fromUser }, room);
});
return true;
},
});

Loading…
Cancel
Save