Compare commits
3 Commits
Author | SHA1 | Date |
---|---|---|
Aleksander Machniak | 05db0f5d87 | 5 years ago |
Aleksander Machniak | 77b6e2606a | 5 years ago |
Aleksander Machniak | ad5f2f0e6f | 5 years ago |
@ -0,0 +1,63 @@
|
||||
Push plugin for Roundcube
|
||||
|
||||
Roundcube plugin that provides support for live updates in mail user interface
|
||||
by utilizing push notifications functionality of IMAP servers.
|
||||
|
||||
|
||||
ARCHITECTURE
|
||||
------------
|
||||
|
||||
The old solution for instant notifications in mailboxes is IMAP IDLE. This has however a big limitation
|
||||
which is a need to use a separate IMAP connection for every folder.
|
||||
|
||||
Recent versions of Dovecot and Cyrus IMAP provide APIs that could be used and might be better than IDLE. For example:
|
||||
|
||||
- https://jmap.io/#push-mechanism
|
||||
- https://www.cyrusimap.org/imap/concepts/features/event-notifications.html
|
||||
- https://wiki.dovecot.org/Plugins/PushNotification
|
||||
|
||||
The plugin contains two components:
|
||||
|
||||
1. HTTP service that handles connections from imap notification services and passes them
|
||||
to the frontend (with some format conversion logic).
|
||||
2. Websocket client (frontend) that receives signals from the service and updates the UI.
|
||||
|
||||
|
||||
REQUIREMENTS
|
||||
------------
|
||||
|
||||
The service uses Swoole extension for PHP (https://www.swoole.co.uk/).
|
||||
|
||||
|
||||
INSTALLATION
|
||||
------------
|
||||
|
||||
1. Rename config.inc.php.dist to config.inc.php in the plugin folder.
|
||||
For available configuration options see config.inc.php.dist file.
|
||||
2. Enable the plugin in Roundcube config.
|
||||
3. Start the service (`php server.php`).
|
||||
Note: You can move the file to a different location, but make sure to set INSTALL_PATH constant
|
||||
to Roundcube location.
|
||||
|
||||
|
||||
INSTALLATION - CYRUS IMAP
|
||||
-------------------------
|
||||
|
||||
0. Make sure the server has PHP with Curl extension installed.
|
||||
1. Make sure notify service is enabled in cyrus.conf.
|
||||
2. Update imapd.conf with:
|
||||
```
|
||||
notify_external: /usr/share/roundcubemail/plugins/push/helpers/notify-cyrus.sh
|
||||
event_notifier: external
|
||||
event_groups: message mailbox flags subscription
|
||||
event_extra_params: diskUsed messages vnd.cmu.unseenMessages flagNames
|
||||
```
|
||||
3. Configure $URL and $TOKEN in notify-cyrus.sh script.
|
||||
4. Restart cyrus-imapd.
|
||||
|
||||
|
||||
NOTES
|
||||
-----
|
||||
|
||||
1. It should be executed with the same user as Roundcube, so it can have access to logs dir.
|
||||
2. It does not work with session_storage=php.
|
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "roundcube/push",
|
||||
"type": "roundcube-plugin",
|
||||
"description": "Instant updates (push notification) support",
|
||||
"license": "GPLv3+",
|
||||
"version": "1.0",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Aleksander Machniak",
|
||||
"email": "alec@alec.pl",
|
||||
"role": "Lead"
|
||||
}
|
||||
],
|
||||
"repositories": [
|
||||
{
|
||||
"type": "composer",
|
||||
"url": "http://plugins.roundcube.net"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=5.3.0",
|
||||
"ext-swoole": "*"
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
// Websocket connection URL
|
||||
// This is where web browsers connect to the service
|
||||
// Supported replacement variables:
|
||||
// %h - user's IMAP hostname
|
||||
// %n - hostname ($_SERVER['SERVER_NAME'])
|
||||
// %t - hostname without the first part
|
||||
// %d - domain (http hostname $_SERVER['HTTP_HOST'] without the first part)
|
||||
// %z - IMAP domain (IMAP hostname without the first part)
|
||||
// %p - Port specified in $config['push_service_port'] (or 9501 if unset)
|
||||
$config['push_url'] = 'wss://%n:%p';
|
||||
|
||||
// To which IP/Port the service should bind to
|
||||
// Note: The service need to be accessible by web browsers and notification services
|
||||
$config['push_service_host'] = '0.0.0.0';
|
||||
$config['push_service_port'] = 9501;
|
||||
|
||||
// Additional security token that external services will have to set
|
||||
// when connecting to http service of the plugin. The token can be specified
|
||||
// either in X-Token header, or Authorization header with value "Bearer <token>".
|
||||
$config['push_token'] = null;
|
||||
|
||||
// HTTP service configuration. For all available options see:
|
||||
// https://www.swoole.co.uk/docs/modules/swoole-server/configuration
|
||||
$config['push_service_config'] = array(
|
||||
// 'chroot' => INSTALL_PATH,
|
||||
// 'heartbeat_idle_time' => 600,
|
||||
// 'heartbeat_check_interval' => 60,
|
||||
// 'pid_file' => 'server.pid',
|
||||
// 'ssl_cert_file' => 'ssl.cert', // PEM not DER
|
||||
// 'ssl_key_file' => 'ssl.key', // PEM not DER
|
||||
// 'ssl_ciphers' => '',
|
||||
);
|
||||
|
||||
// Shared-memory cache size (maximum number of records).
|
||||
// This should be around <users_count> + <active_sessions_count>.
|
||||
$config['push_cache_size'] = 1024;
|
||||
|
||||
// Enables debug logging for http service (<log_dir>/push) and browser console
|
||||
$config['push_debug'] = false;
|
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Push plugin helper for Cyrus IMAP
|
||||
*
|
||||
* @author Aleksander Machniak <alec@alec.pl>
|
||||
*
|
||||
* Copyright (C) 2010-2019 The Roundcube Dev Team
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
///////////////////// CONFIGURATION /////////////////////
|
||||
|
||||
$URL = "http://127.0.0.1:9501";
|
||||
$TOKEN = "xyz";
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
|
||||
if (php_sapi_name() != 'cli') {
|
||||
die('Not on the "shell" (php-cli).');
|
||||
}
|
||||
|
||||
$input = file_get_contents('php://stdin');
|
||||
|
||||
// Debug
|
||||
file_put_contents('/tmp/notify.log', "$input\n", FILE_APPEND);
|
||||
|
||||
$curl = curl_init($URL);
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $input);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, array(
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: ' . strlen($input),
|
||||
'X-Token: ' . $TOKEN
|
||||
));
|
||||
|
||||
curl_exec($curl);
|
||||
curl_close($curl);
|
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Push plugin
|
||||
*
|
||||
* @author Aleksander Machniak <alec@alec.pl>
|
||||
*
|
||||
* Copyright (C) 2010-2019 The Roundcube Dev Team
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Indexed shared memory storage for cross-process cache
|
||||
*/
|
||||
class push_cache extends swoole_table
|
||||
{
|
||||
protected $index;
|
||||
|
||||
function __construct($size)
|
||||
{
|
||||
parent::__construct($size);
|
||||
$this->column('data', swoole_table::TYPE_STRING, 2048);
|
||||
$this->create();
|
||||
|
||||
$this->index = new swoole_table($size);
|
||||
$this->index->column('user', swoole_table::TYPE_STRING, 256);
|
||||
$this->index->create();
|
||||
}
|
||||
|
||||
public function get($key, $field = null)
|
||||
{
|
||||
$result = parent::get($key);
|
||||
if ($result) {
|
||||
$result = json_decode($result['data'], true);
|
||||
|
||||
if ($field) {
|
||||
$result = $result[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function set($key, $value)
|
||||
{
|
||||
return parent::set($key, array('data' => json_encode($value)));
|
||||
}
|
||||
|
||||
public function add_client($client_id, $username, $metadata = array())
|
||||
{
|
||||
$this->index->set($client_id, array('user' => $username));
|
||||
|
||||
$data = $this->get($username) ?: array('sockets' => array());
|
||||
$data = array_merge($data, $metadata);
|
||||
$data['sockets'][] = $client_id;
|
||||
$this->set($username, $data);
|
||||
}
|
||||
|
||||
public function del_client($client_id)
|
||||
{
|
||||
$username = $this->index->get($client_id, 'user');
|
||||
$data = $this->get($username) ?: array();
|
||||
|
||||
if (($key = array_search($client_id, (array) $data['sockets'])) !== false) {
|
||||
unset($data['sockets'][$key]);
|
||||
$data['sockets'] = array_unique($data['sockets']);
|
||||
}
|
||||
|
||||
$this->set($username, $data);
|
||||
|
||||
return $this->index->del($client_id);
|
||||
}
|
||||
}
|
@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Push aka Instant Updates
|
||||
*
|
||||
* @author Aleksander Machniak <alec@alec.pl>
|
||||
*
|
||||
* Copyright (C) 2010-2019 The Roundcube Dev Team
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parser of push notifications as of RFC5423
|
||||
* This format is used e.g. by Cyrus IMAP
|
||||
*/
|
||||
class push_parser_notify
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// $this->service = push_service::get_instance();
|
||||
}
|
||||
|
||||
public function parse($data)
|
||||
{
|
||||
if (!is_array($data) || empty($data) || !isset($data['event'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get event name (remove non-standard prefixes)
|
||||
$event = str_replace('vnd.cmu.', '', $data['event']);
|
||||
$data['event'] = $event;
|
||||
|
||||
// Parse data
|
||||
$common = $this->commonProps($data);
|
||||
$result = $this->{"event$event"}($data);
|
||||
|
||||
if (is_array($result)) {
|
||||
return array_merge($common, $result);
|
||||
}
|
||||
}
|
||||
|
||||
public function __call($name, $arguments)
|
||||
{
|
||||
// Here we end up with unknown/undefined events
|
||||
}
|
||||
|
||||
protected function commonProps($data)
|
||||
{
|
||||
$uri = $this->parseURI($data['uri']);
|
||||
|
||||
$result = array(
|
||||
'event' => $data['event'],
|
||||
'service' => $data['service'],
|
||||
'uidset' => $data['uidset'],
|
||||
'exists' => isset($data['messages']) ? intval($data['messages']) : null,
|
||||
'folder_user' => $uri['user'] ?: $data['user'],
|
||||
'folder_name' => $uri['folder'],
|
||||
);
|
||||
|
||||
if (isset($data['oldMailboxID'])) {
|
||||
$uri = $this->parseURI($data['oldMailboxID']);
|
||||
|
||||
$result['old_folder_user'] = $uri['user'] ?: $data['user'];
|
||||
$result['old_folder_name'] = $uri['folder'];
|
||||
}
|
||||
|
||||
if (isset($data['vnd.cmu.unseenMessages'])) {
|
||||
$result['unseen'] = intval($data['vnd.cmu.unseenMessages']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function eventAclChange($data)
|
||||
{
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
protected function eventFlagsClear($data)
|
||||
{
|
||||
return array(
|
||||
'flags' => explode(' ', $data['flagNames']),
|
||||
);
|
||||
}
|
||||
|
||||
protected function eventFlagsSet($data)
|
||||
{
|
||||
return array(
|
||||
'flags' => explode(' ', $data['flagNames']),
|
||||
);
|
||||
}
|
||||
|
||||
protected function eventLogin($data)
|
||||
{
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
protected function eventLogout($data)
|
||||
{
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
protected function eventMailboxCreate($data)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
protected function eventMailboxDelete($data)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
protected function eventMailboxRename($data)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
protected function eventMailboxSubscribe($data)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
protected function eventMailboxUnSubscribe($data)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
protected function eventMessageAppend($data)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
protected function eventMessageCopy($data)
|
||||
{
|
||||
return array(
|
||||
'old_uidset' => $data['vnd.cmu.oldUidset'],
|
||||
);
|
||||
}
|
||||
|
||||
protected function eventMessageExpire($data)
|
||||
{
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
protected function eventMessageExpunge($data)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
protected function eventMessageMove($data)
|
||||
{
|
||||
// this is non-standard event used in Cyrus IMAP
|
||||
return $this->eventMessageCopy($data);
|
||||
}
|
||||
|
||||
protected function eventMessageNew($data)
|
||||
{
|
||||
// Add some props that might be useful if we want to display
|
||||
// new message notification with message subject, etc.
|
||||
// Maybe we should talk with newmail_notifier plugin
|
||||
$headers = array();
|
||||
|
||||
// Note: messageHeaders is not defined in RFC5423, but used by Cyrus.
|
||||
// Other properties does not include Subject header
|
||||
foreach ((array) $data['messageHeaders'] as $uid => $h) {
|
||||
$headers[$uid] = array(
|
||||
'subject' => $h['Subject'],
|
||||
'from' => $h['From'],
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'headers' => $headers,
|
||||
);
|
||||
}
|
||||
|
||||
protected function eventMessageRead($data)
|
||||
{
|
||||
// This is equivalent of FlagsSet with \Seen flag
|
||||
// so let's imitate it for simplicity
|
||||
return array(
|
||||
'event' => 'FlagsSet',
|
||||
'flags' => array('\\Seen'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function eventMessageTrash($data)
|
||||
{
|
||||
// This is equivalent of FlagsSet with \Deleted flag
|
||||
// so let's imitate it for simplicity
|
||||
return array(
|
||||
'event' => 'FlagsSet',
|
||||
'flags' => array('\\Deleted'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function eventQuotaChange($data)
|
||||
{
|
||||
return array(
|
||||
'quota' => $data['diskQuota'],
|
||||
'used' => $data['diskUsed'],
|
||||
'messages' => $data['maxMessages'],
|
||||
);
|
||||
}
|
||||
|
||||
protected function eventQuotaExceed($data)
|
||||
{
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
protected function eventQuotaWithin($data)
|
||||
{
|
||||
return $this->eventQuotaChange($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse imap folder URI to extract user and folder name
|
||||
*/
|
||||
protected function parseURI($uri)
|
||||
{
|
||||
$_uri = parse_url($uri);
|
||||
|
||||
list($path, $args) = explode(';', $_uri['path'], 2);
|
||||
|
||||
$path = ltrim($path, '/');
|
||||
$params = array();
|
||||
|
||||
// Standard: "imap://test%40example.org@imap.example.org/INBOX";
|
||||
if (!empty($_uri['user'])) {
|
||||
$user = urldecode($_uri['user']);
|
||||
$path = strlen($path) ? urldecode($path) : 'INBOX';
|
||||
}
|
||||
// (Old?) Cyrus IMAP: imap://imap.example.org/user/test.test/Sent%40example.org
|
||||
else if (strpos($path, 'user/') === 0) {
|
||||
$path = array_map('urldecode', explode('/', $path));
|
||||
if (count($path) > 1) {
|
||||
$last = count($path) - 1;
|
||||
$user = $path[1];
|
||||
|
||||
if ($last > 1 && ($pos = strrpos($path[$last], '@'))) {
|
||||
$user .= substr($path[$last], $pos);
|
||||
$path[$last] = substr($path[$last], 0, $pos);
|
||||
}
|
||||
|
||||
$path = implode('/', array_slice($path, 2));
|
||||
if (!strlen($path)) {
|
||||
$path = 'INBOX';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// E.g. for MailboxSubscribe the uri can be also just imap://imap.example.org/Folder
|
||||
|
||||
if (empty($user) && empty($path)) {
|
||||
// invalid uri?
|
||||
}
|
||||
|
||||
if (!empty($args)) {
|
||||
foreach (explode(';', $args) as $arg) {
|
||||
list($key, $val) = explode('=', $arg);
|
||||
$params[urldecode($key)] = urldecode($val);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'user' => $user,
|
||||
'folder' => $path,
|
||||
'params' => $params,
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Push aka Instant Updates
|
||||
*
|
||||
* @author Aleksander Machniak <alec@alec.pl>
|
||||
*
|
||||
* Copyright (C) 2010-2019 The Roundcube Dev Team
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/cache.php';
|
||||
|
||||
class push_service extends rcube
|
||||
{
|
||||
protected $server;
|
||||
protected $clients;
|
||||
protected $parser;
|
||||
protected $token;
|
||||
protected $debug = false;
|
||||
|
||||
|
||||
/**
|
||||
* This implements the 'singleton' design pattern
|
||||
*
|
||||
* @param int $mode Unused
|
||||
* @param string $env Unused
|
||||
*
|
||||
* @return kolab_sync The one and only instance
|
||||
*/
|
||||
public static function get_instance($mode = 0, $env = '')
|
||||
{
|
||||
if (!self::$instance || !is_a(self::$instance, 'push_service')) {
|
||||
self::$instance = new push_service();
|
||||
self::$instance->startup(); // init AFTER object was linked with self::$instance
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialization of class instance
|
||||
*/
|
||||
public function startup()
|
||||
{
|
||||
require_once __DIR__ . '/parser_notify.php';
|
||||
require_once __DIR__ . '/../push.php';
|
||||
|
||||
// Use the plugin to load configuration from its config file
|
||||
$plugins = rcube_plugin_api::get_instance();
|
||||
$plugin = new push($plugins);
|
||||
$plugin->load_config();
|
||||
|
||||
$host = $this->config->get('push_service_host') ?: "0.0.0.0";
|
||||
$port = $this->config->get('push_service_post') ?: 9501;
|
||||
$cache_size = $this->config->get('push_cache_size') ?: 1024;
|
||||
|
||||
$this->debug = $this->config->get('push_debug', false);
|
||||
$this->token = $this->config->get('push_token');
|
||||
$this->clients = new push_cache($cache_size);
|
||||
$this->parser = new push_parser_notify;
|
||||
|
||||
// Setup the server
|
||||
$this->server = new swoole_websocket_server($host, $port); // TODO: SSL
|
||||
$this->server->set((array) $this->config->get('push_service_config'));
|
||||
$this->server->on('open', array($this, 'http_open'));
|
||||
$this->server->on('message', array($this, 'http_message'));
|
||||
$this->server->on('request', array($this, 'http_request'));
|
||||
$this->server->on('close', array($this, 'http_close'));
|
||||
|
||||
$this->log_debug("S: Service start.");
|
||||
|
||||
$this->server->start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we handle connections from websocket client
|
||||
*/
|
||||
public function http_open(swoole_websocket_server $server, swoole_http_request $request)
|
||||
{
|
||||
$this->log_debug("[{$request->fd}] Handshake success");
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we close http/websocket connections
|
||||
*/
|
||||
public function http_close(swoole_websocket_server $server, int $fd)
|
||||
{
|
||||
$this->log_debug("[{$fd}] Closed");
|
||||
|
||||
// Remove connection entry from cache
|
||||
$this->clients->del_client($fd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we handle messages from websocket clients
|
||||
*/
|
||||
public function http_message(swoole_websocket_server $server, swoole_websocket_frame $frame)
|
||||
{
|
||||
$data = json_decode($frame->data, true);
|
||||
|
||||
$this->log_debug("[{$frame->fd}] Received message", $data);
|
||||
|
||||
if ($data && $data['action'] == 'authenticate'
|
||||
&& ($user = $this->authenticate($data['session'], $data['token']))
|
||||
) {
|
||||
$this->log_debug("[{$frame->fd}] Registered session for " . $user['username']);
|
||||
$this->clients->add_client($frame->fd, $user['username'], $user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-websocket request handler.
|
||||
* Here we handle Push notifications from external systems (IMAP servers).
|
||||
*/
|
||||
public function http_request(swoole_http_request $request, swoole_http_response $response)
|
||||
{
|
||||
$this->log_debug("[{$request->fd}] HTTP {$request->server['request_method']} request");
|
||||
|
||||
if ($request->server['request_method'] !== 'POST') {
|
||||
$response->status('404');
|
||||
$response->end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check security token (if configured)
|
||||
if ($this->token) {
|
||||
$token = $request->header['x-token'];
|
||||
if (!$token && preg_match('/^Bearer (.+)/', $request->header['authorization'], $m)) {
|
||||
$token = $m[1];
|
||||
}
|
||||
|
||||
if ($token !== $this->token) {
|
||||
$this->log_debug("[{$request->fd}] 401 Invalid token");
|
||||
$response->status('401');
|
||||
$response->end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send 200 response
|
||||
// $response->header("Content-Type", "text/plain; charset=utf-8");
|
||||
$response->end();
|
||||
|
||||
$this->notification($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to rcube::console(), writes to logs/push if debug option is enabled
|
||||
*/
|
||||
public function log_debug()
|
||||
{
|
||||
if ($this->debug) {
|
||||
$msg = array();
|
||||
foreach (func_get_args() as $arg) {
|
||||
$msg[] = !is_string($arg) ? var_export($arg, true) : $arg;
|
||||
}
|
||||
|
||||
rcube::write_log('push', implode(";\n", $msg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming notification request
|
||||
*/
|
||||
protected function notification($request)
|
||||
{
|
||||
// Read POST data, or JSON from POST body
|
||||
$data = $request->post;
|
||||
if (empty($data)) {
|
||||
$data = $request->rawcontent();
|
||||
if ($data && $data[0] == '{') {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log_debug("Received notification", $data);
|
||||
|
||||
// Parse notification data (convert to internal format)
|
||||
$event = $this->parser->parse($data);
|
||||
|
||||
if (!empty($event) && $event['folder_user']) {
|
||||
$user = $this->clients->get($event['folder_user']);
|
||||
|
||||
if ($user) {
|
||||
$this->broadcast($user, $event);
|
||||
}
|
||||
|
||||
// TODO: broadcast to old_folder_user
|
||||
// TODO: shared folders
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the message to all specified websocket clients
|
||||
*/
|
||||
protected function broadcast($client, $event)
|
||||
{
|
||||
// remove null items
|
||||
$event = array_filter($event, function($v) { return !is_null($v); });
|
||||
|
||||
foreach ((array) $client['sockets'] as $fd) {
|
||||
if ($json = json_encode($event)) {
|
||||
$this->log_debug("[$fd] Sending message to " . $client['username'], $json);
|
||||
$this->server->push($fd, $json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Websocket authentication.
|
||||
*
|
||||
* Checks if specified session ID and request token match
|
||||
* and returns user metadata from that session
|
||||
*/
|
||||
protected function authenticate($session_id, $token)
|
||||
{
|
||||
if (!$token || !$session_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session = rcube_session::factory($this->config);
|
||||
|
||||
if ($data = $session->read($session_id)) {
|
||||
$session = $session->unserialize($data);
|
||||
if ($session && $token === $session['request_token']) {
|
||||
return array(
|
||||
'username' => $session['username'],
|
||||
// 'password' => $session['password'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,347 @@
|
||||
/*
|
||||
* Push plugin
|
||||
*
|
||||
* Websocket code based on https://github.com/mattermost/mattermost-redux/master/client/websocket_client.js
|
||||
*
|
||||
* @author Aleksander Machniak <alec@alec.pl>
|
||||
*
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this file.
|
||||
*
|
||||
* Copyright (C) 2015-2018 Mattermost, Inc.
|
||||
* Copyright (C) 2010-2019 The Roundcube Dev Team
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
var MAX_WEBSOCKET_FAILS = 7;
|
||||
var MIN_WEBSOCKET_RETRY_TIME = 3 * 1000; // 3 sec
|
||||
var MAX_WEBSOCKET_RETRY_TIME = 5 * 60 * 1000; // 5 mins
|
||||
var WEBSOCKET_PING_INTERVAL = 30 * 1000; // 30 sec
|
||||
|
||||
function PushSocketClient()
|
||||
{
|
||||
var Socket;
|
||||
|
||||
this.conn = null;
|
||||
this.connectionUrl = 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.ping_timeout = null;
|
||||
this.debug = false;
|
||||
|
||||
this.initialize = function(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;
|
||||
}
|
||||
|
||||
this.debug = opts.debug;
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (self.conn) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionUrl == null) {
|
||||
console.error('websocket must have connection url');
|
||||
reject('websocket must have connection url');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dispatch) {
|
||||
console.error('websocket must have a dispatch');
|
||||
reject('websocket must have a dispatch');
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.connectFailCount === 0) {
|
||||
self.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.dispatch = dispatch;
|
||||
self.getState = getState;
|
||||
|
||||
self.conn.onopen = function() {
|
||||
self.log('websocket connected');
|
||||
|
||||
if (self.connectFailCount > 0) {
|
||||
self.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) {
|
||||
self.log('websocket closed');
|
||||
}
|
||||
|
||||
self.connectFailCount++;
|
||||
|
||||
clearTimeout(this.ping_timeout);
|
||||
|
||||
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(dispatch, getState, Object.assign({}, opts, {forceConnection: true}));
|
||||
},
|
||||
retryTime
|
||||
);
|
||||
};
|
||||
|
||||
self.conn.onerror = function(evt) {
|
||||
if (self.connectFailCount <= 1) {
|
||||
self.log('websocket error');
|
||||
console.error(evt);
|
||||
}
|
||||
|
||||
clearTimeout(this.ping_timeout);
|
||||
|
||||
if (self.errorCallback) {
|
||||
self.errorCallback(evt, self.dispatch, self.getState);
|
||||
}
|
||||
};
|
||||
|
||||
self.conn.onmessage = function(evt) {
|
||||
var msg = JSON.parse(evt.data);
|
||||
|
||||
self.log(msg);
|
||||
|
||||
if (msg.error) {
|
||||
console.warn(msg);
|
||||
}
|
||||
else if (self.eventCallback) {
|
||||
self.eventCallback(msg, self.dispatch, self.getState);
|
||||
}
|
||||
self.ping();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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.log = function(data)
|
||||
{
|
||||
if (this.debug) {
|
||||
console.log(data);
|
||||
}
|
||||
}
|
||||
|
||||
this.ping = function()
|
||||
{
|
||||
var self = this;
|
||||
clearTimeout(this.ping_timeout);
|
||||
this.ping_timeout = setTimeout(function() { self.sendMessage('ping', {}); }, WEBSOCKET_PING_INTERVAL);
|
||||
}
|
||||
|
||||
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;
|
||||
this.log('websocket closed');
|
||||
}
|
||||
}
|
||||
|
||||
this.sendMessage = function(action, data)
|
||||
{
|
||||
var msg = $.extend({}, data, {action: action, seq: this.sequence++});
|
||||
|
||||
if (this.conn && this.conn.readyState === Socket.OPEN) {
|
||||
this.conn.send(JSON.stringify(msg));
|
||||
this.ping();
|
||||
} else if (!this.conn || this.conn.readyState === Socket.CLOSED) {
|
||||
this.conn = null;
|
||||
this.initialize(this.dispatch, this.getState, {forceConnection: true, platform: this.platform});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and starts websocket connection with Mattermost
|
||||
*/
|
||||
function push_websocket_init()
|
||||
{
|
||||
var api = new PushSocketClient(),
|
||||
onconnect = function() {
|
||||
api.sendMessage('authenticate', {token: rcmail.env.request_token, session: rcmail.env.sessid});
|
||||
};
|
||||
|
||||
api.setEventCallback(function(e) { push_event_handler(e); });
|
||||
api.setFirstConnectCallback(onconnect);
|
||||
api.setReconnectCallback(onconnect);
|
||||
|
||||
api.initialize({}, {}, {connectionUrl: rcmail.env.push_url, debug: rcmail.env.push_debug});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles websocket events
|
||||
*/
|
||||
function push_event_handler(event)
|
||||
{
|
||||
if (!event || !event.event) {
|
||||
return;
|
||||
}
|
||||
|
||||
var event_name = event.event,
|
||||
folder = event.folder_name;
|
||||
|
||||
// All events can provide unseen messages count,
|
||||
// mark the specified folder accordingly
|
||||
if ('unseen' in event && folder) {
|
||||
rcmail.set_unread_count(folder, event.unseen, folder == 'INBOX',
|
||||
event_name == 'MessageNew' ? 'recent' : null);
|
||||
}
|
||||
|
||||
if ('exists' in event && folder === rcmail.env.trash_mailbox) {
|
||||
rcmail.set_trash_count(event.exists);
|
||||
}
|
||||
|
||||
if (folder === rcmail.env.mailbox) {
|
||||
// New messages or deleted messages in the current folder
|
||||
if (event_name === 'MessageNew' || event_name === 'MessageAppend'
|
||||
|| event_name === 'MessageExpire' || event_name === 'MessageExpunge'
|
||||
|| event_name === 'MessageCopy' || event_name === 'MessageMove'
|
||||
) {
|
||||
// TODO: Update messages list
|
||||
}
|
||||
|
||||
if (event_name === 'FlagsSet' || event_name === 'FlagsClear') {
|
||||
// TODO: Update messages list
|
||||
}
|
||||
}
|
||||
|
||||
if (event_name === 'MailboxCreate' || event_name === 'MailboxDelete'
|
||||
|| event_name === 'MailboxRename' || event_name === 'MailboxSubscribe'
|
||||
|| event_name === 'MailboxUnSubscribe'
|
||||
) {
|
||||
// TODO: display a notification and ask the user if he want's to reload the page
|
||||
// to refresh folders list, or (more complicated) update folders list automatically
|
||||
}
|
||||
|
||||
if (event_name === 'MessageNew') {
|
||||
// TODO: display newmail_notifier-like notification about a new message
|
||||
}
|
||||
}
|
||||
|
||||
window.WebSocket && window.rcmail && rcmail.addEventListener('init', function() {
|
||||
push_websocket_init();
|
||||
});
|
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Push aka Instant Updates
|
||||
*
|
||||
* @author Aleksander Machniak <alec@alec.pl>
|
||||
*
|
||||
* Copyright (C) 2010-2019 The Roundcube Dev Team
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
class push extends rcube_plugin
|
||||
{
|
||||
public $task = 'mail';
|
||||
public $rc;
|
||||
|
||||
|
||||
/**
|
||||
* Plugin initialization
|
||||
*/
|
||||
public function init()
|
||||
{
|
||||
$this->rc = rcube::get_instance();
|
||||
|
||||
$this->add_hook('ready', array($this, 'ready'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup hook handler
|
||||
*/
|
||||
public function ready($args)
|
||||
{
|
||||
if ($this->rc->output->type == 'html' && !$this->rc->action) {
|
||||
$this->load_config();
|
||||
|
||||
$debug = (bool) $this->rc->config->get('push_debug');
|
||||
$port = $this->rc->config->get('push_service_port', 9501);
|
||||
$url = $this->rc->config->get('push_url') ?: 'wss://%n:%p';
|
||||
$url = rcube_utils::parse_host($url);
|
||||
$url = str_replace('%p', $port, $url);
|
||||
|
||||
$this->rc->output->set_env('push_url', $url);
|
||||
$this->rc->output->set_env('push_debug', $debug);
|
||||
$this->rc->output->set_env('sessid', session_id());
|
||||
|
||||
$this->include_script('push.js');
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Push aka Instant Updates
|
||||
*
|
||||
* @author Aleksander Machniak <alec@alec.pl>
|
||||
*
|
||||
* Copyright (C) 2010-2019 The Roundcube Dev Team
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
if (php_sapi_name() != 'cli') {
|
||||
die('Not on the "shell" (php-cli).');
|
||||
}
|
||||
|
||||
define('INSTALL_PATH', __DIR__ . '/../../');
|
||||
|
||||
require_once INSTALL_PATH . 'program/include/iniset.php';
|
||||
require_once INSTALL_PATH . 'plugins/push/lib/service.php';
|
||||
|
||||
// Start the service
|
||||
$PUSH = push_service::get_instance();
|
Loading…
Reference in New Issue