kolab_chat: Add direct-message notifications, use one token per session

This commit is contained in:
Aleksander Machniak 2018-07-06 12:48:14 +00:00
parent 4fc313aaed
commit 71fed5584b
5 changed files with 511 additions and 53 deletions

View file

@ -11,16 +11,14 @@
ProxyPreserveHost Off
RewriteEngine On
RewriteCond %{REQUEST_URI} /api/v[0-9]+/(users/)?websocket [NC,OR]
RewriteCond %{REQUEST_URI} (/mattermost)?/api/v[0-9]+/(users/)?websocket [NC,OR]
RewriteCond %{HTTP:UPGRADE} ^WebSocket$ [NC,OR]
RewriteCond %{HTTP:CONNECTION} ^Upgrade$ [NC]
RewriteRule .* ws://mattermost.example.com:8065%{REQUEST_URI} [P,QSA,L]
ProxyPass /mattermost http://mattermost.example.com:8065
ProxyPass /static http://mattermost.example.com:8065/static
ProxyPass /help http://mattermost.example.com:8065/help
ProxyPass /api http://mattermost.example.com:8065/api
ProxyPass /api/v4/websocket ws://mattermost.example.com:8065/api/v4/websocket
ProxyPass /api/v4/users/websocket ws://mattermost.example.com:8065/api/v4/users/websocket
RewriteRule (/mattermost)?(/api/v[0-9]+/(users/)?websocket) ws://mattermost.example.com:8065$2 [P,QSA,L]
ProxyPass /mattermost http://mattermost.example.com:8065
ProxyPass /static http://mattermost.example.com:8065/static
ProxyPass /help http://mattermost.example.com:8065/help
ProxyPass /api http://mattermost.example.com:8065/api
2. Enabling CORS connections in Mattermost config: AllowCorsFrom:"*"
*/

View file

@ -23,8 +23,8 @@
class kolab_chat_mattermost
{
public $rc;
public $plugin;
private $rc;
private $plugin;
/**
@ -41,62 +41,193 @@ class kolab_chat_mattermost
/**
* Returns location of the chat app
*
* @param bool $websocket Return websocket URL
*
* @return string The chat app location
*/
public function url()
public function url($websocket = false)
{
return rtrim($this->rc->config->get('kolab_chat_url'), '/');
$url = rtrim($this->rc->config->get('kolab_chat_url'), '/');
if ($websocket) {
$url = str_replace(array('http://', 'https://'), array('ws://', 'wss://'), $url);
$url .= '/api/v4/websocket';
}
else if ($this->rc->action == 'index' && $this->rc->task == 'kolab-chat') {
if (($channel = rcube_utils::get_input_value('_channel', rcube_utils::INPUT_GET))
&& ($channel = $this->get_channel($channel))
) {
// FIXME: This does not work yet because team_id is empty for direct-message channels
$url .= '/' . urlencode($channel['team_name']) . '/channels/' . urlencode($channel['id']);
}
}
return $url;
}
/**
* Authenticates the user and sets cookies to auto-login the user
* Add/register UI elements
*/
public function ui()
{
if ($this->rc->task != 'kolab-chat') {
$this->plugin->include_script("js/mattermost.js");
$this->plugin->add_label('openchat', 'directmessage');
}
else if ($this->get_token()) {
rcube_utils::setcookie('MMUSERID', $_SESSION['mattermost'][0], 0, false);
rcube_utils::setcookie('MMAUTHTOKEN', $_SESSION['mattermost'][1], 0, false);
}
}
/**
* Driver specific actions handler
*/
public function action()
{
$result = array(
'url' => $this->url(true),
'token' => $this->get_token(),
);
echo rcube_output::json_serialize($result);
exit;
}
/**
* Returns the Mattermost session token
* Note: This works only if the user/pass is the same in Kolab and Mattermost
*
* @param string $user Username
* @param string $pass Password
* @return string Session token
*/
public function authenticate($user, $pass)
protected function get_token()
{
$url = $this->url() . '/api/v4/users/login';
$user = $_SESSION['username'];
$pass = $this->rc->decrypt($_SESSION['password']);
$config = array(
// Use existing token if still valid
if (!empty($_SESSION['mattermost'])) {
$user_id = $_SESSION['mattermost'][0];
$token = $_SESSION['mattermost'][1];
try {
$request = $this->get_api_request('GET', '/api/v4/users/me');
$request->setHeader('Authorization', "Bearer $token");
$response = $request->send();
$status = $response->getStatus();
if ($status != 200) {
$token = null;
}
}
catch (Exception $e) {
rcube::raise_error($e, true, false);
$token = null;
}
}
// Request a new session token
if (empty($token)) {
$request = $this->get_api_request('POST', '/api/v4/users/login');
$request->setBody(json_encode(array(
'login_id' => $user,
'password' => $pass,
)));
// send request to the API, get token and user ID
try {
$response = $request->send();
$status = $response->getStatus();
$token = $response->getHeader('Token');
$body = json_decode($response->getBody(), true);
if ($status == 200) {
$user_id = $body['id'];
}
else if (is_array($body) && $body['message']) {
throw new Exception($body['message']);
}
else {
throw new Exception("Failed to authenticate the chat user ($user). Status: $status");
}
}
catch (Exception $e) {
rcube::raise_error($e, true, false);
}
}
if ($user_id && $token) {
$_SESSION['mattermost'] = array($user_id, $token);
return $token;
}
}
/**
* Returns the Mattermost channel info
*
* @param string $channel_id Channel ID
*
* @return array Channel information
*/
protected function get_channel($channel_id)
{
$token = $this->get_token();
if ($token) {
$channel = $this->api_get('/api/v4/channels/' . urlencode($channel_id), $token);
}
if (is_array($channel) && !empty($channel['team_id'])) {
if ($team = $this->api_get('/api/v4/teams/' . urlencode($channel['team_id']), $token)) {
$channel['team_name'] = $team['name'];
}
}
return $channel;
}
/**
* Return HTTP/Request2 instance for Mattermost API connection
*/
protected function get_api_request($type, $path)
{
$url = rtrim($this->rc->config->get('kolab_chat_url'), '/');
$defaults = array(
'store_body' => true,
'follow_redirects' => true,
);
$config = array_merge($config, (array) $this->rc->config->get('kolab_chat_http_request'));
$request = libkolab::http_request($url, 'POST', $config);
$config = array_merge($defaults, (array) $this->rc->config->get('kolab_chat_http_request'));
$request->setBody(json_encode(array(
'login_id' => $user,
'password' => $pass,
)));
return libkolab::http_request($url . $path, $type, $config);
}
// send request to the API, get token and user ID
try {
$response = $request->send();
$status = $response->getStatus();
$token = $response->getHeader('Token');
$body = json_decode($response->getBody(), true);
if ($status == 200) {
$user_id = $body['id'];
}
else if (is_array($body) && $body['message']) {
throw new Exception($body['message']);
}
else {
throw new Exception("Failed to authenticate the chat user ($user). Status: $status");
}
}
catch (Exception $e) {
rcube::raise_error($e, true, false);
/**
* Call API GET command
*/
protected function api_get($path, $token = null)
{
if (!$token) {
$token = $this->get_token();
}
// Set cookies
if ($user_id && $token) {
rcube_utils::setcookie('MMUSERID', $user_id, 0, false);
rcube_utils::setcookie('MMAUTHTOKEN', $token, 0, false);
if ($token) {
try {
$request = $this->get_api_request('GET', $path);
$request->setHeader('Authorization', "Bearer $token");
$response = $request->send();
$status = $response->getStatus();
$body = $response->getBody();
if ($status == 200) {
return json_decode($body, true);
}
}
catch (Exception $e) {
rcube::raise_error($e, true, false);
}
}
}
}

View file

@ -0,0 +1,315 @@
/**
* Mattermost driver
* Websocket code based on https://github.com/mattermost/mattermost-redux/master/client/websocket_client.js
*
* @author Aleksander Machniak <machniak@kolabsys.com>
*
* @licstart The following is the entire license notice for the
* JavaScript code in this file.
*
* Copyright (C) 2015-2018, Kolab Systems AG <contact@kolabsys.com>
* Copyright (C) 2015-2018, Mattermost, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice
* for the JavaScript code in this file.
*/
var MAX_WEBSOCKET_FAILS = 7;
var MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
var MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
function MMWebSocketClient()
{
var Socket;
this.conn = null;
this.connectionUrl = null;
this.token = null;
this.sequence = 1;
this.connectFailCount = 0;
this.eventCallback = null;
this.firstConnectCallback = null;
this.reconnectCallback = null;
this.errorCallback = null;
this.closeCallback = null;
this.connectingCallback = null;
this.dispatch = null;
this.getState = null;
this.stop = false;
this.platform = '';
this.initialize = function(token, dispatch, getState, opts)
{
var forceConnection = opts.forceConnection || true,
webSocketConnector = opts.webSocketConnector || WebSocket,
connectionUrl = opts.connectionUrl,
platform = opts.platform,
self = this;
if (platform) {
this.platform = platform;
}
if (forceConnection) {
this.stop = false;
}
return new Promise(function(resolve, reject) {
if (self.conn) {
resolve();
return;
}
if (connectionUrl == null) {
console.log('websocket must have connection url');
reject('websocket must have connection url');
return;
}
if (!dispatch) {
console.log('websocket must have a dispatch');
reject('websocket must have a dispatch');
return;
}
if (self.connectFailCount === 0) {
console.log('websocket connecting to ' + connectionUrl);
}
Socket = webSocketConnector;
if (self.connectingCallback) {
self.connectingCallback(dispatch, getState);
}
var regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/;
var captured = (regex).exec(connectionUrl);
var origin;
if (captured) {
origin = captured[0];
if (platform === 'android') {
// this is done cause for android having the port 80 or 443 will fail the connection
// the websocket will append them
var split = origin.split(':');
var port = split[2];
if (port === '80' || port === '443') {
origin = split[0] + ':' + split[1];
}
}
} else {
// If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway
console.warn('websocket failed to parse origin from ' + connectionUrl);
}
self.conn = new Socket(connectionUrl, [], {headers: {origin}});
self.connectionUrl = connectionUrl;
self.token = token;
self.dispatch = dispatch;
self.getState = getState;
self.conn.onopen = function() {
if (token && platform !== 'android') {
// we check for the platform as a workaround until we fix on the server that further authentications
// are ignored
self.sendMessage('authentication_challenge', {token});
}
if (self.connectFailCount > 0) {
console.log('websocket re-established connection');
if (self.reconnectCallback) {
self.reconnectCallback(self.dispatch, self.getState);
}
} else if (self.firstConnectCallback) {
self.firstConnectCallback(self.dispatch, self.getState);
}
self.connectFailCount = 0;
resolve();
};
self.conn.onclose = function() {
self.conn = null;
self.sequence = 1;
if (self.connectFailCount === 0) {
console.log('websocket closed');
}
self.connectFailCount++;
if (self.closeCallback) {
self.closeCallback(self.connectFailCount, self.dispatch, self.getState);
}
var retryTime = MIN_WEBSOCKET_RETRY_TIME;
// If we've failed a bunch of connections then start backing off
if (self.connectFailCount > MAX_WEBSOCKET_FAILS) {
retryTime = MIN_WEBSOCKET_RETRY_TIME * self.connectFailCount;
if (retryTime > MAX_WEBSOCKET_RETRY_TIME) {
retryTime = MAX_WEBSOCKET_RETRY_TIME;
}
}
setTimeout(function() {
if (self.stop) {
return;
}
self.initialize(token, dispatch, getState, Object.assign({}, opts, {forceConnection: true}));
},
retryTime
);
};
self.conn.onerror = function(evt) {
if (self.connectFailCount <= 1) {
console.log('websocket error');
console.log(evt);
}
if (self.errorCallback) {
self.errorCallback(evt, self.dispatch, self.getState);
}
};
self.conn.onmessage = function(evt) {
var msg = JSON.parse(evt.data);
if (msg.seq_reply) {
if (msg.error) {
console.warn(msg);
}
} else if (self.eventCallback) {
self.eventCallback(msg, self.dispatch, self.getState);
}
};
});
}
this.setConnectingCallback = function(callback)
{
this.connectingCallback = callback;
}
this.setEventCallback = function(callback)
{
this.eventCallback = callback;
}
this.setFirstConnectCallback = function(callback)
{
this.firstConnectCallback = callback;
}
this.setReconnectCallback = function(callback)
{
this.reconnectCallback = callback;
}
this.setErrorCallback = function(callback)
{
this.errorCallback = callback;
}
this.setCloseCallback = function(callback) {
this.closeCallback = callback;
}
this.close = function(stop)
{
this.stop = stop;
this.connectFailCount = 0;
this.sequence = 1;
if (this.conn && this.conn.readyState === Socket.OPEN) {
this.conn.onclose = function(){};
this.conn.close();
this.conn = null;
console.log('websocket closed');
}
}
this.sendMessage = function(action, data)
{
var msg = {
action,
seq: this.sequence++,
data
};
if (this.conn && this.conn.readyState === Socket.OPEN) {
this.conn.send(JSON.stringify(msg));
} else if (!this.conn || this.conn.readyState === Socket.CLOSED) {
this.conn = null;
this.initialize(this.token, this.dispatch, this.getState, {forceConnection: true, platform: this.platform});
}
}
}
/**
* Initializes and starts websocket connection with Mattermost
*/
function mattermost_websocket_init(url, token)
{
var api = new MMWebSocketClient();
api.setEventCallback(function(e) {
mattermost_event_handler(e);
});
api.initialize(token, {}, {}, {connectionUrl: url});
}
/**
* Handles websocket events
*/
function mattermost_event_handler(event)
{
// We're interested only in direct messages for now
if (event.event == 'posted' && event.data.channel_type == 'D') {
var user = event.data.sender_name,
channel_id = event.broadcast.channel_id,
// channel_name = event.data.channel_display_name,
msg = rcmail.gettext('kolab_chat.directmessage').replace('$u', user),
link = $('<a>').text(rcmail.gettext('kolab_chat.openchat'))
.attr('href', '?_task=kolab-chat&_channel=' + urlencode(channel_id));
if (rcmail.env.kolab_chat_extwin) {
link.attr('target', '_blank').attr('href', link.attr('href') + '&redirect=1');
}
msg = $('<p>').text(msg + ' ').append(link).html();
// FIXME: Should we display it indefinitely?
rcmail.display_message(msg, 'notice chat', 10 * 60 * 1000, 'chat-user-' + user);
}
}
window.WebSocket && window.rcmail && rcmail.addEventListener('init', function() {
// Use ajax to get the token for websocket connection
$.ajax({
type: 'GET',
url: '?_task=kolab-chat&_action=action&_get=token',
success: function(data) {
data = JSON.parse(data);
if (data && data.token) {
// rcmail.set_env({mattermost_url: data.url, mattermost_token: data.token});
mattermost_websocket_init(data.url, data.token);
}
}
});
});

View file

@ -23,10 +23,9 @@
class kolab_chat extends rcube_plugin
{
public $task = '^(?!login|logout).*$';
public $noajax = true;
public $rc;
public $task = '^(?!login|logout).*$';
private $rc;
private $driver;
@ -66,8 +65,9 @@ class kolab_chat extends rcube_plugin
// Register UI end-points
$this->register_task('kolab-chat');
$this->register_action('index', array($this, 'ui'));
$this->register_action('action', array($this, 'action'));
if (!$this->rc->output->framed) {
if ($this->rc->output->type == 'html' && !$this->rc->output->get_env('framed')) {
$this->rc->output->set_env('kolab_chat_extwin', (bool) $extwin);
$this->rc->output->add_script(
"rcmail.addEventListener('beforeswitch-task', function(p) {
@ -87,9 +87,11 @@ class kolab_chat extends rcube_plugin
'label' => 'kolab_chat.chat',
'type' => 'link',
), 'taskbar');
$this->driver->ui();
}
if ($args['task'] == 'settings') {
if ($this->rc->output->type == 'html' && $args['task'] == 'settings') {
// add hooks for Chat settings
$this->add_hook('preferences_sections_list', array($this, 'preferences_sections_list'));
$this->add_hook('preferences_list', array($this, 'preferences_list'));
@ -104,7 +106,7 @@ class kolab_chat extends rcube_plugin
function ui()
{
if ($this->driver) {
$this->driver->authenticate($_SESSION['username'], $this->rc->decrypt($_SESSION['password']));
$this->driver->ui();
$url = rcube::JQ($this->driver->url());
@ -127,6 +129,16 @@ class kolab_chat extends rcube_plugin
}
}
/**
* Handler for driver specific actions
*/
function action()
{
if ($this->driver) {
$this->driver->action();
}
}
/**
* Handler for preferences_sections_list hook.
* Adds Chat settings section into preferences sections list.

View file

@ -10,3 +10,5 @@
$labels['chat'] = 'Chat';
$labels['showinextwin'] = 'Open chat application in a new window';
$labels['directmessage'] = 'A direct message from $u.';
$labels['openchat'] = 'Open chat';