Skip to content

Commit

Permalink
[NEW] Add omnichannel external frame feature (#17038)
Browse files Browse the repository at this point in the history
  • Loading branch information
d-gubert authored Apr 3, 2020
1 parent f6085fa commit 6e27845
Show file tree
Hide file tree
Showing 16 changed files with 211 additions and 7 deletions.
39 changes: 39 additions & 0 deletions app/livechat/client/externalFrame/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
function ab2str(buf: ArrayBuffer): string {
return String.fromCharCode(...new Uint16Array(buf));
}

function str2ab(str: string): ArrayBuffer {
const buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char
const bufView = new Uint16Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}

export async function generateKey(): Promise<string> {
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const exportedKey = await crypto.subtle.exportKey('jwk', key);
return JSON.stringify(exportedKey);
}

export async function getKeyFromString(keyStr: string): Promise<CryptoKey> {
const key = JSON.parse(keyStr);
return crypto.subtle.importKey('jwk', key, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
}

export async function encrypt(text: string, key: CryptoKey): Promise<string> {
const vector = crypto.getRandomValues(new Uint8Array(16));
const data = new TextEncoder().encode(text);
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: vector }, key, data);
const cipherText = new Uint8Array(enc);
return encodeURIComponent(btoa(ab2str(vector) + ab2str(cipherText)));
}

export async function decrypt(data: string, key: CryptoKey): Promise<string> {
const binaryData = atob(decodeURIComponent(data));
const vector = new Uint8Array(new Uint16Array(str2ab(binaryData.slice(0, 16))));
const buffer = new Uint8Array(new Uint16Array(str2ab(binaryData.slice(16))));
const decoded = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: vector }, key, buffer);
return new TextDecoder().decode(decoded);
}
5 changes: 5 additions & 0 deletions app/livechat/client/externalFrame/externalFrameContainer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template name="ExternalFrameContainer">
<div class="flex-nav">
<iframe class="external-frame" src="{{ externalFrameUrl }}"></iframe>
</div>
</template>
49 changes: 49 additions & 0 deletions app/livechat/client/externalFrame/externalFrameContainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { Session } from 'meteor/session';
import { ReactiveVar } from 'meteor/reactive-var';

import { APIClient } from '../../../utils/client';
import { settings } from '../../../settings/client';
import { encrypt, getKeyFromString } from './crypto';

import './externalFrameContainer.html';

Template.ExternalFrameContainer.helpers({
externalFrameUrl() {
const authToken = Template.instance().authToken.get();

if (!authToken) {
return '';
}

const frameURLSetting = settings.get('Omnichannel_External_Frame_URL');

try {
const frameURL = new URL(frameURLSetting);

frameURL.searchParams.append('uid', Meteor.userId());
frameURL.searchParams.append('rid', Session.get('openedRoom'));
frameURL.searchParams.append('t', authToken);

return frameURL.toString();
} catch {
console.error('Invalid URL provided to external frame');

return '';
}
},
});

Template.ExternalFrameContainer.onCreated(async function() {
this.authToken = new ReactiveVar();

const { 'X-Auth-Token': authToken } = APIClient.getCredentials();
const keyStr = settings.get('Omnichannel_External_Frame_Encryption_JWK');

if (keyStr) {
return this.authToken.set(await encrypt(authToken, await getKeyFromString(keyStr)));
}

this.authToken.set(authToken);
});
10 changes: 10 additions & 0 deletions app/livechat/client/externalFrame/generateNewKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Meteor } from 'meteor/meteor';

import { generateKey } from './crypto';

Meteor.methods({
async omnichannelExternalFrameGenerateKey() {
const key = await generateKey();
Meteor.call('saveSetting', 'Omnichannel_External_Frame_Encryption_JWK', key);
},
});
3 changes: 3 additions & 0 deletions app/livechat/client/externalFrame/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import './generateNewKey';
import './tabBar';
import './externalFrameContainer';
23 changes: 23 additions & 0 deletions app/livechat/client/externalFrame/tabBar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';

import { TabBar } from '../../../ui-utils/client';
import { settings } from '../../../settings/client';


Meteor.startup(function() {
Tracker.autorun(function() {
if (!settings.get('Omnichannel_External_Frame_Enabled')) {
return TabBar.removeButton('omnichannelExternalFrame');
}

TabBar.addButton({
groups: ['live'],
id: 'omnichannelExternalFrame',
i18nTitle: 'Omnichannel_External_Frame',
icon: 'cube',
template: 'ExternalFrameContainer',
order: -1,
});
});
});
1 change: 1 addition & 0 deletions app/livechat/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ import './hooks/onRenderRoom';
import './startup/notifyUnreadRooms';
import './views/sideNav/livechat';
import './views/sideNav/livechatFlex';
import './externalFrame';
5 changes: 5 additions & 0 deletions app/livechat/client/stylesheets/livechat.less
Original file line number Diff line number Diff line change
Expand Up @@ -633,3 +633,8 @@
.livechat-current-chats-add-filter-button {
margin-top: 17px;
}

.external-frame {
width: 100%;
height: 100%;
}
10 changes: 10 additions & 0 deletions app/livechat/server/externalFrame/generateNewKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Meteor } from 'meteor/meteor';

Meteor.methods({
// eslint-disable-next-line @typescript-eslint/no-empty-function
omnichannelExternalFrameGenerateKey() {
return {
message: 'Generating_key',
};
}, // only to prevent error when calling the client method
});
2 changes: 2 additions & 0 deletions app/livechat/server/externalFrame/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './settings';
import './generateNewKey';
38 changes: 38 additions & 0 deletions app/livechat/server/externalFrame/settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { settings } from '../../../settings/server/functions/settings';

settings.addGroup('Omnichannel', function() {
this.section('External Frame', function() {
this.add('Omnichannel_External_Frame_Enabled', false, {
type: 'boolean',
public: true,
alert: 'Experimental_Feature_Alert',
});

this.add('Omnichannel_External_Frame_URL', '', {
type: 'string',
public: true,
enableQuery: {
_id: 'Omnichannel_External_Frame_Enabled',
value: true,
},
});

this.add('Omnichannel_External_Frame_Encryption_JWK', '', {
type: 'string',
public: true,
enableQuery: {
_id: 'Omnichannel_External_Frame_Enabled',
value: true,
},
});

this.add('Omnichannel_External_Frame_GenerateKey', 'omnichannelExternalFrameGenerateKey', {
type: 'action',
actionText: 'Generate_new_key',
enableQuery: {
_id: 'Omnichannel_External_Frame_Enabled',
value: true,
},
});
});
});
1 change: 1 addition & 0 deletions app/livechat/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,6 @@ import './lib/stream/queueManager';
import './sendMessageBySMS';
import './api';
import './api/rest';
import './externalFrame';

export { Livechat } from './lib/Livechat';
3 changes: 2 additions & 1 deletion app/settings/server/functions/settings.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export namespace settings {
export function get(name: string, callback: (key: string, value: any) => void): string;
export function get(name: string, callback?: (key: string, value: any) => void): string;
export function updateById(_id: string, value: any, editor?: string): number;
}
15 changes: 9 additions & 6 deletions app/utils/client/lib/RestApiClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ export const APIClient = {
return APIClient._jqueryFormDataCall(endpoint, params, formData, xhrOptions);
},

getCredentials() {
return {
'X-User-Id': Meteor._localStorage.getItem(Accounts.USER_ID_KEY),
'X-Auth-Token': Meteor._localStorage.getItem(Accounts.LOGIN_TOKEN_KEY),
};
},

_generateQueryFromParams(params) {
let query = '';
if (params && typeof params === 'object') {
Expand All @@ -53,8 +60,7 @@ export const APIClient = {
url: `${ baseURI }api/${ endpoint }${ query }`,
headers: Object.assign({
'Content-Type': 'application/json',
'X-User-Id': Meteor._localStorage.getItem(Accounts.USER_ID_KEY),
'X-Auth-Token': Meteor._localStorage.getItem(Accounts.LOGIN_TOKEN_KEY),
...APIClient.getCredentials(),
}, headers),
data: JSON.stringify(body),
success: function _rlGetSuccess(result) {
Expand Down Expand Up @@ -103,10 +109,7 @@ export const APIClient = {
return xhr;
},
url: `${ baseURI }api/${ endpoint }${ query }`,
headers: {
'X-User-Id': Meteor._localStorage.getItem(Accounts.USER_ID_KEY),
'X-Auth-Token': Meteor._localStorage.getItem(Accounts.LOGIN_TOKEN_KEY),
},
headers: APIClient.getCredentials(),
data: formData,
processData: false,
contentType: false,
Expand Down
7 changes: 7 additions & 0 deletions packages/rocketchat-i18n/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,8 @@
"Full_Screen": "Full Screen",
"Gaming": "Gaming",
"General": "General",
"Generate_new_key": "Generate a new key",
"Generating_key": "Generating key",
"Get_link": "Get Link",
"Generate_New_Link": "Generate New Link",
"github_no_public_email": "You don't have any email as public email in your GitHub account",
Expand Down Expand Up @@ -2539,6 +2541,11 @@
"Old Colors": "Old Colors",
"Old Colors (minor)": "Old Colors (minor)",
"Older_than": "Older than",
"Omnichannel_External_Frame": "External Frame",
"Omnichannel_External_Frame_Enabled": "External frame enabled",
"Omnichannel_External_Frame_URL": "External frame URL",
"Omnichannel_External_Frame_Encryption_JWK": "Encryption key (JWK)",
"Omnichannel_External_Frame_Encryption_JWK_Description": "If provided it will encrypt the user's token with the provided key and the external system will need to decrypt the data to access the token",
"On": "On",
"Online": "Online",
"online": "online",
Expand Down
7 changes: 7 additions & 0 deletions packages/rocketchat-i18n/i18n/pt-BR.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,8 @@
"Full_Screen": "Tela cheia",
"Gaming": "Jogos",
"General": "Geral",
"Generate_new_key": "Gerar uma nova chave",
"Generating_key": "Gerando chave",
"Get_link": "Obter Link",
"Generate_New_Link": "Gerar Novo Link",
"github_no_public_email": "Você não possui um e-mail público em sua conta do GitHub",
Expand Down Expand Up @@ -2283,6 +2285,11 @@
"Old Colors": "Cores antigas",
"Old Colors (minor)": "Cores antigas (menores)",
"Older_than": "Mais velho que",
"Omnichannel_External_Frame": "Frame Externo",
"Omnichannel_External_Frame_Enabled": "Frame Externo habilitado",
"Omnichannel_External_Frame_URL": "URL do Frame Externo",
"Omnichannel_External_Frame_Encryption_JWK": "Chave de criptografia (JWK)",
"Omnichannel_External_Frame_Encryption_JWK_Description": "Se a chave for fornecida o token do usuário será encriptado com esta chave e a outra aplicação precisará descriptografar o campo de token para ter acesso a informação",
"On": "Em",
"Online": "Online",
"online": "online",
Expand Down

0 comments on commit 6e27845

Please sign in to comment.