diff --git a/plugins/kolab_chat/config.inc.php.dist b/plugins/kolab_chat/config.inc.php.dist index bb2d0b8a..cc5fe534 100644 --- a/plugins/kolab_chat/config.inc.php.dist +++ b/plugins/kolab_chat/config.inc.php.dist @@ -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:"*" */ diff --git a/plugins/kolab_chat/drivers/mattermost.php b/plugins/kolab_chat/drivers/mattermost.php index 158d0077..47764c2b 100644 --- a/plugins/kolab_chat/drivers/mattermost.php +++ b/plugins/kolab_chat/drivers/mattermost.php @@ -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); + } } } } diff --git a/plugins/kolab_chat/js/mattermost.js b/plugins/kolab_chat/js/mattermost.js new file mode 100644 index 00000000..23ff6b99 --- /dev/null +++ b/plugins/kolab_chat/js/mattermost.js @@ -0,0 +1,315 @@ +/** + * Mattermost driver + * Websocket code based on https://github.com/mattermost/mattermost-redux/master/client/websocket_client.js + * + * @author Aleksander Machniak + * + * @licstart The following is the entire license notice for the + * JavaScript code in this file. + * + * Copyright (C) 2015-2018, Kolab Systems AG + * 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 . + * + * @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 = $('').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 = $('

').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); + } + } + }); +}); diff --git a/plugins/kolab_chat/kolab_chat.php b/plugins/kolab_chat/kolab_chat.php index 9f57bcb3..e988e105 100644 --- a/plugins/kolab_chat/kolab_chat.php +++ b/plugins/kolab_chat/kolab_chat.php @@ -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. diff --git a/plugins/kolab_chat/localization/en_US.inc b/plugins/kolab_chat/localization/en_US.inc index 1e7c6b0e..5561f2a9 100644 --- a/plugins/kolab_chat/localization/en_US.inc +++ b/plugins/kolab_chat/localization/en_US.inc @@ -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';