diff --git a/.meteor/packages b/.meteor/packages index 6131d799c7db..79d8a508e997 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -197,6 +197,7 @@ rocketchat:lazy-load tap:i18n underscore@1.0.10 rocketchat:bigbluebutton +rocketchat:contacts rocketchat:mailmessages juliancwirko:postcss littledata:synced-cron diff --git a/.meteor/versions b/.meteor/versions index 203746025451..86b0023e8475 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -145,6 +145,7 @@ rocketchat:cas@1.0.0 rocketchat:channel-settings@0.0.1 rocketchat:channel-settings-mail-messages@0.0.1 rocketchat:colors@0.0.1 +rocketchat:contacts@0.0.1 rocketchat:cors@0.0.1 rocketchat:crowd@1.0.0 rocketchat:custom-oauth@1.0.0 diff --git a/package.json b/package.json index a3fd4a47b879..7feac0e67a14 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "Rocket.Chat", "description": "The Ultimate Open Source WebChat Platform", "version": "0.73.0-develop", - "author": { "name": "Rocket.Chat", "url": "https://rocket.chat/" @@ -151,6 +150,7 @@ "fibers": "^3.1.1", "file-type": "^10.6.0", "filesize": "^3.6.1", + "google-libphonenumber": "3.2.1", "grapheme-splitter": "^1.0.4", "gridfs-stream": "^1.1.1", "he": "^1.2.0", diff --git a/packages/rocketchat-api/server/v1/misc.js b/packages/rocketchat-api/server/v1/misc.js index abbbcb156425..573e178fe760 100644 --- a/packages/rocketchat-api/server/v1/misc.js +++ b/packages/rocketchat-api/server/v1/misc.js @@ -2,6 +2,9 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { TAPi18n } from 'meteor/tap:i18n'; import { RocketChat } from 'meteor/rocketchat:lib'; +import { PhoneNumberUtil } from 'google-libphonenumber'; + +const phoneUtil = PhoneNumberUtil.getInstance(); RocketChat.API.v1.addRoute('info', { authRequired: false }, { get() { @@ -202,6 +205,10 @@ RocketChat.API.v1.addRoute('invite.sms', { authRequired: true }, { throw new Meteor.Error('error-phone-param-not-provided', 'The required "phone" param is required.'); } const phone = this.bodyParams.phone.replace(/-|\s/g, ''); + if (!phoneUtil.isValidNumber(phoneUtil.parse(phone))) { + return RocketChat.API.v1.failure('Invalid number'); + } + const result = Meteor.runAsUser(this.userId, () => Meteor.call('sendInvitationSMS', [phone])); if (result.indexOf(phone) >= 0) { return RocketChat.API.v1.success(); @@ -210,3 +217,15 @@ RocketChat.API.v1.addRoute('invite.sms', { authRequired: true }, { } }, }); + +RocketChat.API.v1.addRoute('query.contacts', { authRequired: true }, { + post() { + const hashes = this.bodyParams.weakHashes; + if (!hashes) { + return RocketChat.API.v1.failure('weakHashes param not present.'); + } + const result = Meteor.runAsUser(this.userId, () => Meteor.call('queryContacts', hashes)); + return RocketChat.API.v1.success({ strongHashes:result }); + + }, +}); diff --git a/packages/rocketchat-contacts/package.js b/packages/rocketchat-contacts/package.js new file mode 100644 index 000000000000..e3be732682d4 --- /dev/null +++ b/packages/rocketchat-contacts/package.js @@ -0,0 +1,14 @@ +Package.describe({ + name: 'rocketchat:contacts', + version: '0.0.1', + git: '', +}); + +Package.onUse(function(api) { + api.use('rocketchat:lib'); + api.use('ecmascript'); + + api.addFiles('server/index.js', 'server'); + api.addFiles('server/service.js', 'server'); + api.addFiles('server/startup.js', 'server'); +}); diff --git a/packages/rocketchat-contacts/server/index.js b/packages/rocketchat-contacts/server/index.js new file mode 100644 index 000000000000..8a4304d1a0e7 --- /dev/null +++ b/packages/rocketchat-contacts/server/index.js @@ -0,0 +1,114 @@ +/* globals SyncedCron */ + +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; +const service = require('./service.js'); +const provider = new service.Provider(); + +function refreshContactsHashMap() { + let phoneFieldName = ''; + RocketChat.settings.get('Contacts_Phone_Custom_Field_Name', function(name, fieldName) { + phoneFieldName = fieldName; + }); + + let emailFieldName = ''; + RocketChat.settings.get('Contacts_Email_Custom_Field_Name', function(name, fieldName) { + emailFieldName = fieldName; + }); + + let useDefaultEmails = false; + RocketChat.settings.get('Contacts_Use_Default_Emails', function(name, fieldName) { + useDefaultEmails = fieldName; + }); + + const contacts = []; + const cursor = Meteor.users.find({ active:true }); + + let phoneFieldArray = []; + if (phoneFieldName) { + phoneFieldArray = phoneFieldName.split(','); + } + + let emailFieldArray = []; + if (emailFieldName) { + emailFieldArray = emailFieldName.split(','); + } + + let dict; + + const phonePattern = /^\+?[1-9]\d{1,14}$/; + const rfcMailPattern = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + cursor.forEach((user) => { + const discoverable = RocketChat.getUserPreference(user, 'isPublicAccount'); + if (discoverable !== false) { + if (phoneFieldArray.length > 0) { + dict = user; + for (let i = 0;i < phoneFieldArray.length - 1;i++) { + if (phoneFieldArray[i] in dict) { + dict = dict[phoneFieldArray[i]]; + } + } + let phone = dict[phoneFieldArray[phoneFieldArray.length - 1]]; + if (phone && _.isString(phone)) { + phone = phone.replace(/[^0-9+]|_/g, ''); + if (phonePattern.test(phone)) { + contacts.push({ d:phone, u:user.username }); + } + } + + } + + if (emailFieldArray.length > 0) { + dict = user; + for (let i = 0;i < emailFieldArray.length - 1;i++) { + if (emailFieldArray[i] in dict) { + dict = dict[emailFieldArray[i]]; + } + } + const email = dict[emailFieldArray[emailFieldArray.length - 1]]; + if (email && _.isString(email)) { + if (rfcMailPattern.test(email)) { + contacts.push({ d:email, u:user.username }); + } + } + } + + if (useDefaultEmails && 'emails' in user) { + user.emails.forEach((email) => { + if (email.verified) { + contacts.push({ d:email.address, u:user.username }); + } + }); + } + } + }); + provider.setHashedMap(provider.generateHashedMap(contacts)); +} + +Meteor.methods({ + queryContacts(weakHashes) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'queryContactse', + }); + } + return provider.queryContacts(weakHashes); + }, +}); + +const jobName = 'Refresh_Contacts_Hashes'; + +Meteor.startup(() => { + Meteor.defer(() => { + refreshContactsHashMap(); + + RocketChat.settings.get('Contacts_Background_Sync_Interval', function(name, processingFrequency) { + SyncedCron.remove(jobName); + SyncedCron.add({ + name: jobName, + schedule: (parser) => parser.cron(`*/${ processingFrequency } * * * *`), + job: refreshContactsHashMap, + }); + }); + }); +}); diff --git a/packages/rocketchat-contacts/server/service.js b/packages/rocketchat-contacts/server/service.js new file mode 100644 index 000000000000..e69f7d7a3391 --- /dev/null +++ b/packages/rocketchat-contacts/server/service.js @@ -0,0 +1,81 @@ +const crypto = require('crypto'); + +class ContactsProvider { + + constructor() { + this.contactsWeakHashMap = {}; + } + + addContact(contact, username) { + const weakHash = this.getWeakHash(contact); + const strongHash = this.getStrongHash(contact); + + if (weakHash in this.contactsWeakHashMap) { + if (this.contactsWeakHashMap[weakHash].indexOf(strongHash) === -1) { + this.contactsWeakHashMap[weakHash].push({ + h:strongHash, + u:username, + }); + } + } else { + this.contactsWeakHashMap[weakHash] = [{ h:strongHash, u:username }]; + } + } + + generateHashedMap(contacts) { + const contactsWeakHashMap = {}; + contacts.forEach((contact) => { + const weakHash = this.getWeakHash(contact.d); + const strongHash = this.getStrongHash(contact.d); + if (weakHash in contactsWeakHashMap) { + if (contactsWeakHashMap[weakHash].indexOf(strongHash) === -1) { + contactsWeakHashMap[weakHash].push({ h:strongHash, u:contact.u }); + } + } else { + contactsWeakHashMap[weakHash] = [{ h:strongHash, u:contact.u }]; + } + }); + return contactsWeakHashMap; + } + + setHashedMap(contactsWeakHashMap) { + this.contactsWeakHashMap = contactsWeakHashMap; + } + + getStrongHash(contact) { + return crypto.createHash('sha1').update(contact).digest('hex'); + } + + getWeakHash(contact) { + return crypto.createHash('sha1').update(contact).digest('hex').substr(3, 6); + } + + queryContacts(contactWeakHashList) { + let result = []; + contactWeakHashList.forEach((weakHash) => { + if (weakHash in this.contactsWeakHashMap) { + result = result.concat(this.contactsWeakHashMap[weakHash]); + } + }); + return result; + } + + removeContact(contact, username) { + const weakHash = this.getWeakHash(contact); + const strongHash = this.getStrongHash(contact); + + if (weakHash in this.contactsWeakHashMap && this.contactsWeakHashMap[weakHash].indexOf(strongHash) >= 0) { + this.contactsWeakHashMap[weakHash].splice(this.contactsWeakHashMap[weakHash].indexOf({ h:strongHash, u:username }), 1); + + if (!this.contactsWeakHashMap[weakHash].length) { delete this.contactsWeakHashMap[weakHash]; } + } + } + + reset() { + this.contactsWeakHashMap = {}; + } +} + +module.exports = { + Provider: ContactsProvider, +}; diff --git a/packages/rocketchat-contacts/server/startup.js b/packages/rocketchat-contacts/server/startup.js new file mode 100644 index 000000000000..8c8b63a9a28d --- /dev/null +++ b/packages/rocketchat-contacts/server/startup.js @@ -0,0 +1,25 @@ +RocketChat.settings.addGroup('Contacts', function() { + this.add('Contacts_Phone_Custom_Field_Name', '', { + type: 'string', + public: true, + i18nDescription: 'Contacts_Phone_Custom_Field_Name_Description', + }); + + this.add('Contacts_Use_Default_Emails', true, { + type: 'boolean', + public: true, + i18nDescription: 'Contacts_Use_Default_Emails_Description', + }); + + this.add('Contacts_Email_Custom_Field_Name', '', { + type: 'string', + public: true, + i18nDescription: 'Contacts_Email_Custom_Field_Name_Description', + }); + + this.add('Contacts_Background_Sync_Interval', 10, { + type: 'int', + public: true, + i18nDescription: 'Contacts_Background_Sync_Interval_Description', + }); +}); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index f060964ceb50..f666d409bdf9 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -594,6 +594,10 @@ "Consumer_Goods": "Consumer Goods", "Contains_Security_Fixes": "Contains Security Fixes", "Contact": "Contact", + "Contacts_Background_Sync_Interval_Description":"Interval in minutes after which the contacts are synced form db by the discovery service", + "Contacts_Email_Custom_Field_Name_Description":"Comma seperated keys in hierarcy order to access email in the User object. Eg. services,ssotest,email . Can be used in addition to default emails.", + "Contacts_Phone_Custom_Field_Name_Description":"Comma seperated keys in hierarcy order to access phone number in User object. Eg. services,ssotest,telephoneNumber", + "Contacts_Use_Default_Emails_Description":"Use verified emails from the user accounts", "Content": "Content", "Continue": "Continue", "Continuous_sound_notifications_for_new_livechat_room": "Continuous sound notifications for new livechat room", diff --git a/packages/rocketchat-lib/server/startup/settings.js b/packages/rocketchat-lib/server/startup/settings.js index 7cb5f032b9dc..c3b7415c4596 100644 --- a/packages/rocketchat-lib/server/startup/settings.js +++ b/packages/rocketchat-lib/server/startup/settings.js @@ -450,6 +450,11 @@ RocketChat.settings.addGroup('Accounts', function() { public: true, i18nLabel: 'Notifications_Sound_Volume', }); + this.add('Accounts_Default_User_Preferences_isPublicAccount', true, { + type: 'boolean', + public: true, + i18nLabel: 'Is_Public_Account', + }); }); this.section('Avatar', function() { diff --git a/packages/rocketchat-ui-account/client/accountPreferences.html b/packages/rocketchat-ui-account/client/accountPreferences.html index 231a4134cebf..6fb98aa12eef 100644 --- a/packages/rocketchat-ui-account/client/accountPreferences.html +++ b/packages/rocketchat-ui-account/client/accountPreferences.html @@ -27,6 +27,13 @@

{{_ "Localization"}}

{{_ "Global"}}

+
+ +
+ + +
+
diff --git a/packages/rocketchat-ui-account/client/accountPreferences.js b/packages/rocketchat-ui-account/client/accountPreferences.js index 679e68429aec..ca548e9da2a2 100644 --- a/packages/rocketchat-ui-account/client/accountPreferences.js +++ b/packages/rocketchat-ui-account/client/accountPreferences.js @@ -170,6 +170,7 @@ Template.accountPreferences.onCreated(function() { return s.trim(e); })); data.dontAskAgainList = Array.from(document.getElementById('dont-ask').options).map((option) => ({ action: option.value, label: option.text })); + data.isPublicAccount = JSON.parse($('#isPublicAccount').find('input:checked').val()); let reload = false; diff --git a/tests/end-to-end/api/00-miscellaneous.js b/tests/end-to-end/api/00-miscellaneous.js index 7ed3f9f5fc99..3315aabc7c8d 100644 --- a/tests/end-to-end/api/00-miscellaneous.js +++ b/tests/end-to-end/api/00-miscellaneous.js @@ -116,7 +116,7 @@ describe('miscellaneous', function() { 'saveMobileBandwidth', 'collapseMediaByDefault', 'hideUsernames', 'hideRoles', 'hideFlexTab', 'hideAvatars', 'sidebarViewMode', 'sidebarHideAvatar', 'sidebarShowUnread', 'sidebarShowFavorites', 'sidebarGroupByType', 'sendOnEnter', 'messageViewMode', 'emailNotificationMode', 'roomCounterSidebar', 'newRoomNotification', 'newMessageNotification', - 'muteFocusedConversations', 'notificationsSoundVolume']; + 'muteFocusedConversations', 'notificationsSoundVolume', 'isPublicAccount']; expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('_id', credentials['X-User-Id']); expect(res.body).to.have.property('username', login.user);