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 @@ -1,35 +1,33 @@ * * Copyright (C) 2014-2018, Kolab Systems AG * * 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 . */ class kolab_chat_mattermost { - public $rc; - public $plugin; + private $rc; + private $plugin; /** * Object constructor * * @param rcube_plugin $plugin Kolab_chat plugin object */ public function __construct($plugin) { $this->rc = rcube::get_instance(); $this->plugin = $plugin; } /** * 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) + { + $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; + } + + /** + * 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() { - return rtrim($this->rc->config->get('kolab_chat_url'), '/'); + $result = array( + 'url' => $this->url(true), + 'token' => $this->get_token(), + ); + + echo rcube_output::json_serialize($result); + exit; } /** - * Authenticates the user and sets cookies to auto-login the user + * 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( - 'store_body' => true, - 'follow_redirects' => true, - ); + // 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"); - $config = array_merge($config, (array) $this->rc->config->get('kolab_chat_http_request')); - $request = libkolab::http_request($url, 'POST', $config); + $response = $request->send(); + $status = $response->getStatus(); - $request->setBody(json_encode(array( - 'login_id' => $user, - 'password' => $pass, - ))); + 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); + // 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']; + 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"); + } } - else if (is_array($body) && $body['message']) { - throw new Exception($body['message']); + catch (Exception $e) { + rcube::raise_error($e, true, false); } - else { - throw new Exception("Failed to authenticate the chat user ($user). Status: $status"); + } + + 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']; } } - catch (Exception $e) { - rcube::raise_error($e, true, false); + + 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($defaults, (array) $this->rc->config->get('kolab_chat_http_request')); + + return libkolab::http_request($url . $path, $type, $config); + } + + /** + * 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 @@ -1,206 +1,218 @@ * * Copyright (C) 2014-2018, Kolab Systems AG * * 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 . */ class kolab_chat extends rcube_plugin { - public $task = '^(?!login|logout).*$'; - public $noajax = true; - public $rc; + public $task = '^(?!login|logout).*$'; + private $rc; private $driver; public function init() { $this->rc = rcube::get_instance(); $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook handler, initializes/enables Chat */ public function startup($args) { // the files module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('kolab_chat_disabled') || !$this->rc->config->get('kolab_chat_enabled', true)) { return; } $this->add_texts('localization/'); $this->load_config(); $extwin = $this->rc->config->get('kolab_chat_extwin'); $driver = $this->rc->config->get('kolab_chat_driver', 'mattermost'); if (!$driver || !file_exists(__DIR__ . "/drivers/$driver.php")) { return; } // Load the driver require_once __DIR__ . "/drivers/$driver.php"; $class_name = "kolab_chat_$driver"; $this->driver = new $class_name($this); // 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) { if (p == 'kolab-chat' && rcmail.env.kolab_chat_extwin) { rcmail.open_window('?_task=kolab-chat&redirect=1', false, false, true); return false; } });", 'foot' ); $this->add_button(array( 'command' => 'kolab-chat', 'class' => 'button-chat', 'classsel' => 'button-chat button-selected', 'innerclass' => 'button-inner', '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')); $this->add_hook('preferences_save', array($this, 'preferences_save')); } } /** * Display chat iframe wrapped by Roundcube interface elements (taskmenu) * or a dummy page with redirect to the chat app. */ function ui() { if ($this->driver) { - $this->driver->authenticate($_SESSION['username'], $this->rc->decrypt($_SESSION['password'])); + $this->driver->ui(); $url = rcube::JQ($this->driver->url()); if (!empty($_GET['redirect'])) { echo '' . '' . ''; exit; } else { $this->rc->output->add_script( "rcmail.addEventListener('init', function() {" . "rcmail.location_href('$url', rcmail.get_frame_window(rcmail.env.contentframe));" . "});", 'foot' ); $this->rc->output->send('kolab_chat.chat'); } } } + /** + * 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. * * @param array Original parameters * * @return array Modified parameters */ function preferences_sections_list($p) { $p['list']['kolab-chat'] = array( 'id' => 'kolab-chat', 'section' => $this->gettext('chat'), 'class' => 'chat' ); return $p; } /** * Handler for preferences_list hook. * Adds options blocks into Chat settings sections in Preferences. * * @param array Original parameters * * @return array Modified parameters */ function preferences_list($p) { if ($p['section'] != 'kolab-chat') { return $p; } $no_override = array_flip((array) $this->rc->config->get('dont_override')); $p['blocks']['main']['name'] = $this->gettext('mainoptions'); if (!isset($no_override['kolab_chat_extwin'])) { if (!$p['current']) { $p['blocks']['main']['content'] = true; return $p; } $field_id = 'rcmfd_kolab_chat_extwin'; $input = new html_checkbox(array('name' => '_kolab_chat_extwin', 'id' => $field_id, 'value' => 1)); $p['blocks']['main']['options']['kolab_chat_extwin'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('showinextwin'))), 'content' => $input->show($this->rc->config->get('kolab_chat_extwin') ? 1 : 0), ); } if ($p['current']) { // update env flag in the parent window $this->rc->output->command('parent.set_env', array('kolab_chat_extwin' => (bool) $this->rc->config->get('kolab_chat_extwin'))); } return $p; } /** * Handler for preferences_save hook. * Executed on Chat settings form submit. * * @param array Original parameters * * @return array Modified parameters */ function preferences_save($p) { if ($p['section'] == 'kolab-chat') { $p['prefs'] = array( 'kolab_chat_extwin' => isset($_POST['_kolab_chat_extwin']), ); } return $p; } } 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 @@ -1,12 +1,14 @@