Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\Utils\Config;
/**
* @since 9.1.0
*/
class ConfigDataProvider
{
public function __construct(
private Config $config,
) {}
public function isEnabled(): bool
{
return (bool) $this->config->get('useWebSocket');
}
public function isDebugMode(): bool
{
return (bool) $this->config->get('webSocketDebugMode');
}
public function useSecureServer(): bool
{
return (bool) $this->config->get('webSocketUseSecureServer');
}
public function getPort(): ?string
{
$port = $this->config->get('webSocketPort');
if (!$port) {
return null;
}
return (string) $port;
}
public function getPhpExecutablePath(): ?string
{
return $this->config->get('phpExecutablePath');
}
public function getSslCertificateFile(): ?string
{
return $this->config->get('webSocketSslCertificateFile');
}
public function allowSelfSignedSsl(): bool
{
return (bool) $this->config->get('webSocketSslAllowSelfSigned');
}
public function getSslCertificatePassphrase(): ?string
{
return $this->config->get('webSocketSslCertificatePassphrase');
}
public function getSslCertificateLocalPrivateKey(): ?string
{
return $this->config->get('webSocketSslCertificateLocalPrivateKey');
}
public function getMessager(): ?string
{
return $this->config->get('webSocketMessager');
}
}

View File

@@ -0,0 +1,590 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\Utils\Json;
use GuzzleHttp\Psr7\Query;
use Psr\Http\Message\RequestInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\ServerProtocol as WAMP;
use Ratchet\Wamp\Topic;
use Ratchet\Wamp\WampConnection;
use Ratchet\Wamp\WampServerInterface;
use React\ChildProcess\Process;
use Symfony\Component\Process\PhpExecutableFinder;
use Exception;
use RuntimeException;
class Pusher implements WampServerInterface
{
/** @var string[] */
private $categoryList;
/** @var array<string, array<string, mixed>> */
protected $categoriesData;
protected bool $isDebugMode = false;
/** @var array<string, string> */
protected $connectionIdUserIdMap = [];
/** @var array<string, string[]> */
protected $userIdConnectionIdListMap = [];
/** @var array<string, string[]> */
protected $connectionIdTopicIdListMap = [];
/** @var array<string, ConnectionInterface> */
protected $connections = [];
/** @var array<string, Topic<object>> */
protected $topicHash = [];
private string $phpExecutablePath;
/**
* @param array<string, array<string, mixed>> $categoriesData
*/
public function __construct(
array $categoriesData = [],
?string $phpExecutablePath = null,
bool $isDebugMode = false
) {
$this->categoryList = array_keys($categoriesData);
$this->categoriesData = $categoriesData;
if (!$phpExecutablePath) {
$phpExecutablePath = (new PhpExecutableFinder)->find() ?: null;
}
if (!$phpExecutablePath) {
if ($isDebugMode) {
$this->log("Error: No php-executable-path.");
}
throw new RuntimeException("No php-executable-path.");
}
$this->phpExecutablePath = $phpExecutablePath;
$this->isDebugMode = $isDebugMode;
}
/**
* @param Topic<object> $topic
* @return void
*/
public function onSubscribe(ConnectionInterface $conn, $topic)
{
$topicId = $topic->getId();
if (!$topicId) {
return;
}
if (!$this->isTopicAllowed($topicId)) {
return;
}
/** @var string $connectionId */
$connectionId = $conn->resourceId ?? throw new RuntimeException();
$userId = $this->getUserIdByConnection($conn);
if (!$userId) {
return;
}
if (!isset($this->connectionIdTopicIdListMap[$connectionId])) {
$this->connectionIdTopicIdListMap[$connectionId] = [];
}
$checkCommand = $this->getAccessCheckCommandForTopic($conn, $topic);
if ($checkCommand) {
$checkCommand = $checkCommand . ' 2>&1';
$process = new Process($checkCommand);
$process->start();
$process->on('exit', function ($exitCode) use ($topic, $connectionId, $topicId, $userId) {
if ($exitCode !== 0) {
if ($this->isDebugMode) {
$this->log("$connectionId: check access failed for topic $topicId for user $userId");
}
return;
}
if ($this->isDebugMode) {
$this->log("$connectionId: check access succeed for topic $topicId for user $userId");
}
$this->addTopicForUser($connectionId, $topic, $userId);
});
return;
}
$this->addTopicForUser($connectionId, $topic, $userId);
}
/**
* @param Topic<object> $topic
* @return void
*/
public function onUnSubscribe(ConnectionInterface $conn, $topic)
{
$topicId = $topic->getId();
if (!$topicId) {
return;
}
if (!$this->isTopicAllowed($topicId)) {
return;
}
/** @var string $connectionId */
$connectionId = $conn->resourceId ?? throw new RuntimeException();
$userId = $this->getUserIdByConnection($conn);
if (!$userId) {
return;
}
if (!isset($this->connectionIdTopicIdListMap[$connectionId])) {
return;
}
$index = array_search($topicId, $this->connectionIdTopicIdListMap[$connectionId]);
if ($index === false) {
return;
}
if ($this->isDebugMode) {
$this->log("$connectionId: remove topic $topicId for user $userId");
}
unset($this->connectionIdTopicIdListMap[$connectionId][$index]);
$this->connectionIdTopicIdListMap[$connectionId] =
array_values($this->connectionIdTopicIdListMap[$connectionId]);
}
/**
* @return array<string, mixed>
*/
private function getCategoryData(string $topicId): array
{
$arr = explode('.', $topicId);
$category = $arr[0];
if (array_key_exists($category, $this->categoriesData)) {
$data = $this->categoriesData[$category];
} else if (array_key_exists($topicId, $this->categoriesData)) {
$data = $this->categoriesData[$topicId];
} else {
$data = [];
}
return $data;
}
/**
* @return array<string, mixed>
*/
private function getParamsFromTopicId(string $topicId): array
{
$arr = explode('.', $topicId);
$data = $this->getCategoryData($topicId);
$params = [];
if (array_key_exists('paramList', $data)) {
foreach ($data['paramList'] as $i => $item) {
/** @var string $item */
if (isset($arr[$i + 1])) {
$params[$item] = $arr[$i + 1];
} else {
$params[$item] = '';
}
}
}
return $params;
}
/**
* @param Topic<object> $topic
*/
private function getAccessCheckCommandForTopic(ConnectionInterface $conn, $topic): ?string
{
$topicId = $topic->getId();
$params = $this->getParamsFromTopicId($topicId);
$params['userId'] = $this->getUserIdByConnection($conn);
if (!$params['userId']) {
$conn->close();
return null;
}
$data = $this->getCategoryData($topic->getId());
if (!array_key_exists('accessCheckCommand', $data)) {
return null;
}
$command = $this->phpExecutablePath . " command.php " . $data['accessCheckCommand'];
foreach ($params as $key => $value) {
$command = str_replace(
':' . $key,
escapeshellarg($value),
$command
);
}
return $command;
}
/**
* @param string $topicId
* @return bool
*/
private function isTopicAllowed($topicId)
{
[$category] = explode('.', $topicId);
return in_array($topicId, $this->categoryList) || in_array($category, $this->categoryList);
}
/**
* @param string $userId
* @return string[]
*/
private function getConnectionIdListByUserId($userId)
{
if (!isset($this->userIdConnectionIdListMap[$userId])) {
return [];
}
return $this->userIdConnectionIdListMap[$userId];
}
/**
* @return ?string
*/
private function getUserIdByConnection(ConnectionInterface $conn)
{
$connectionId = $conn->resourceId ?? '';
if (!isset($this->connectionIdUserIdMap[$connectionId])) {
return null;
}
return $this->connectionIdUserIdMap[$connectionId];
}
/**
* @param string $userId
* @return void
*/
private function subscribeUser(ConnectionInterface $conn, $userId)
{
/** @var string $resourceId */
$resourceId = $conn->resourceId ?? '';
$this->connectionIdUserIdMap[$resourceId] = $userId;
if (!isset($this->userIdConnectionIdListMap[$userId])) {
$this->userIdConnectionIdListMap[$userId] = [];
}
if (!in_array($resourceId, $this->userIdConnectionIdListMap[$userId])) {
$this->userIdConnectionIdListMap[$userId][] = $resourceId;
}
$this->connections[$resourceId] = $conn;
if ($this->isDebugMode) {
$this->log("$resourceId: user $userId subscribed");
}
}
/**
* @param string $userId
* @return void
*/
private function unsubscribeUser(ConnectionInterface $conn, $userId)
{
$resourceId = $conn->resourceId ?? '';
unset($this->connectionIdUserIdMap[$resourceId]);
if (isset($this->userIdConnectionIdListMap[$userId])) {
$index = array_search($resourceId, $this->userIdConnectionIdListMap[$userId]);
if ($index !== false) {
unset($this->userIdConnectionIdListMap[$userId][$index]);
$this->userIdConnectionIdListMap[$userId] = array_values($this->userIdConnectionIdListMap[$userId]);
}
}
if ($this->isDebugMode) {
$this->log("$resourceId: user $userId unsubscribed");
}
}
/**
* @return void
*/
public function onOpen(ConnectionInterface $conn)
{
if ($this->isDebugMode) {
$resourceId = $conn->resourceId ?? '';
$this->log("$resourceId: open");
}
/** @var RequestInterface $httpRequest */
$httpRequest = $conn->httpRequest ?? throw new RuntimeException();
$query = $httpRequest->getUri()->getQuery();
$params = Query::parse($query ?: '');
if (empty($params['userId']) || empty($params['authToken'])) {
$this->closeConnection($conn);
return;
}
$authToken = preg_replace('/[^a-zA-Z0-9\-]+/', '', $params['authToken']);
$userId = preg_replace('/[^a-zA-Z0-9\-]+/', '', $params['userId']);
if (!$authToken || !$userId) {
$this->closeConnection($conn);
return;
}
$command = "$this->phpExecutablePath command.php AuthTokenCheck $authToken $userId 2>&1";
$process = new Process($command);
$process->start();
$process->on('exit', function ($exitCode) use ($conn, $userId) {
if ($exitCode !== 0) {
$this->closeConnection($conn);
return;
}
$this->subscribeUser($conn, $userId);
$this->sendWelcome($conn);
});
}
/**
* @return void
*/
private function closeConnection(ConnectionInterface $conn)
{
$userId = $this->getUserIdByConnection($conn);
if ($userId) {
$this->unsubscribeUser($conn, $userId);
}
$conn->close();
}
/**
* @return void
*/
public function onClose(ConnectionInterface $conn)
{
$connectionId = $conn->resourceId ?? '';
if ($this->isDebugMode) {
$this->log("$connectionId: close");
}
$userId = $this->getUserIdByConnection($conn);
if ($userId) {
$this->unsubscribeUser($conn, $userId);
}
unset($this->connections[$connectionId]);
}
/**
* @param string $id
* @param Topic<object> $topic
* @param array<string, mixed> $params
* @return void
*/
public function onCall(ConnectionInterface $conn, $id, $topic, array $params)
{
if (!method_exists($conn, 'callError')) {
return;
}
$conn->callError($id, $topic, 'You are not allowed to make calls')
->close();
}
/**
* @param Topic<object> $topic
* @param string $event
* @param array<int|string, mixed> $exclude
* @param array<int|string, mixed> $eligible
* @return void
*/
public function onPublish(ConnectionInterface $conn, $topic, $event, array $exclude, array $eligible)
{
$topicId = $topic->getId();
if ($topicId === '') {
return;
}
$conn->close();
}
/**
* @return void
*/
public function onError(ConnectionInterface $conn, Exception $e)
{
}
public function onMessageReceive(string $message): void
{
$data = json_decode($message);
if (!property_exists($data, 'topicId')) {
return;
}
$userId = $data->userId ?? null;
$topicId = $data->topicId ?? null;
if (!$topicId) {
return;
}
if (!$this->isTopicAllowed($topicId)) {
return;
}
if ($userId) {
foreach ($this->getConnectionIdListByUserId($userId) as $connectionId) {
if (!isset($this->connections[$connectionId])) {
continue;
}
if (!isset($this->connectionIdTopicIdListMap[$connectionId])) {
continue;
}
/** @var WampConnection $connection */
$connection = $this->connections[$connectionId];
if (in_array($topicId, $this->connectionIdTopicIdListMap[$connectionId])) {
if ($this->isDebugMode) {
$this->log("send $topicId for connection $connectionId");
}
$connection->event($topicId, $data);
}
}
if ($this->isDebugMode) {
$this->log("message $topicId for user $userId");
}
return;
}
$topic = $this->topicHash[$topicId] ?? null;
if ($topic) {
$topic->broadcast($data);
if ($this->isDebugMode) {
$this->log("send $topicId to all");
}
}
if ($this->isDebugMode) {
$this->log("message $topicId for all");
}
}
private function log(string $msg): void
{
echo "[" . date('Y-m-d H:i:s') . "] " . $msg . "\n";
}
private function addTopicForUser(string $connectionId, Topic $topic, string $userId): void
{
$topicId = $topic->getId();
if (!$topicId) {
return;
}
if (!in_array($topicId, $this->connectionIdTopicIdListMap[$connectionId])) {
if ($this->isDebugMode) {
$this->log("$connectionId: add topic $topicId for user $userId");
}
$this->connectionIdTopicIdListMap[$connectionId][] = $topicId;
}
$this->topicHash[$topicId] = $topic;
}
private function sendWelcome(ConnectionInterface $conn): void
{
/**
* @noinspection PhpPossiblePolymorphicInvocationInspection
* @phpstan-ignore property.notFound
*/
$sessionId = $conn->WAMP->sessionId;
$conn->send(Json::encode([WAMP::MSG_WELCOME, $sessionId, 1]));
}
}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket\Ratchet;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\ServerProtocol;
class EspoServerProtocol extends ServerProtocol
{
/**
* @inheritDoc
* @phpstan-ignore missingType.return
*/
public function onOpen(ConnectionInterface $conn)
{
$decor = new EspoWampConnection($conn);
$this->connections->attach($conn, $decor);
$this->_decorating->onOpen($decor);
}
}

View File

@@ -0,0 +1,49 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket\Ratchet;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\WampConnection;
use stdClass;
class EspoWampConnection extends WampConnection
{
/**
* @noinspection PhpMissingParentConstructorInspection
*/
public function __construct(ConnectionInterface $conn)
{
$this->wrappedConn = $conn;
$this->WAMP = new stdClass;
$this->WAMP->sessionId = str_replace('.', '', uniqid((string) mt_rand(), true));
$this->WAMP->prefixes = [];
}
}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket\Ratchet;
use Ratchet\Wamp\TopicManager;
use Ratchet\Wamp\WampServer;
use Ratchet\Wamp\WampServerInterface;
/**
* Customized so that the welcome response is not sent on open. It will be sent after async access check.
*/
class EspoWampServer extends WampServer
{
/**
* @noinspection PhpMissingParentConstructorInspection
*/
public function __construct(WampServerInterface $app)
{
$this->wampProtocol = new EspoServerProtocol(new TopicManager($app));
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
/**
* Sends a message to a web-socket server.
*/
interface Sender
{
/**
* Send a message to a web-socket server.
*/
public function send(string $message): void;
}

View File

@@ -0,0 +1,64 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Core\Binding\Factory;
use RuntimeException;
/**
* @implements Factory<Sender>
*/
class SenderFactory implements Factory
{
private const DEFAULT_MESSAGER = 'ZeroMQ';
public function __construct(
private InjectableFactory $injectableFactory,
private ConfigDataProvider $config,
private Metadata $metadata,
) {}
public function create(): Sender
{
$messager = $this->config->getMessager() ?? self::DEFAULT_MESSAGER;
/** @var ?class-string<Sender> $className */
$className = $this->metadata->get(['app', 'webSocket', 'messagers', $messager, 'senderClassName']);
if (!$className) {
throw new RuntimeException("No sender for messager '$messager'.");
}
return $this->injectableFactory->create($className);
}
}

View File

@@ -0,0 +1,124 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\Utils\Metadata;
use React\EventLoop\Factory as EventLoopFactory;
use React\Socket\Server as SocketServer;
use React\Socket\SecureServer as SocketSecureServer;
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
/**
* Starts a web-socket server.
*/
class ServerStarter
{
/** @var array<string, array<string, mixed>> */
private array $categoriesData;
private ?string $phpExecutablePath;
private bool $isDebugMode;
private bool $useSecureServer;
private string $port;
public function __construct(
private Subscriber $subscriber,
private ConfigDataProvider $configDataProvider,
Metadata $metadata
) {
$this->categoriesData = $metadata->get(['app', 'webSocket', 'categories'], []);
$this->phpExecutablePath = $this->configDataProvider->getPhpExecutablePath();
$this->isDebugMode = $this->configDataProvider->isDebugMode();
$this->useSecureServer = $this->configDataProvider->useSecureServer();
$port = $this->configDataProvider->getPort();
if (!$port) {
$port = $this->useSecureServer ? '8443' : '8080';
}
$this->port = $port;
}
/**
* Start a web-socket server.
*/
public function start(): void
{
$loop = EventLoopFactory::create();
$pusher = new Pusher($this->categoriesData, $this->phpExecutablePath, $this->isDebugMode);
$this->subscriber->subscribe($pusher, $loop);
$socketServer = new SocketServer('0.0.0.0:' . $this->port, $loop);
if ($this->useSecureServer) {
$sslParams = $this->getSslParams();
$socketServer = new SocketSecureServer($socketServer, $loop, $sslParams);
}
$wsServer = new WsServer(new Ratchet\EspoWampServer($pusher));
$wsServer->enableKeepAlive($loop, 60);
new IoServer(
new HttpServer($wsServer),
$socketServer
);
$loop->run();
}
/**
* @return array<string, mixed>
*/
private function getSslParams(): array
{
$sslParams = [
'local_cert' => $this->configDataProvider->getSslCertificateFile(),
'allow_self_signed' => $this->configDataProvider->allowSelfSignedSsl(),
'verify_peer' => false,
];
if ($this->configDataProvider->getSslCertificatePassphrase()) {
$sslParams['passphrase'] = $this->configDataProvider->getSslCertificatePassphrase();
}
if ($this->configDataProvider->getSslCertificateLocalPrivateKey()) {
$sslParams['local_pk'] = $this->configDataProvider->getSslCertificateLocalPrivateKey();
}
return $sslParams;
}
}

View File

@@ -0,0 +1,81 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Json;
use stdClass;
use Throwable;
class Submission
{
public function __construct(
private Sender $sender,
private Log $log,
private ConfigDataProvider $configDataProvider,
) {}
/**
* Submit to a web-socket server.
*
* Since 9.1.0 performs check whether enabled in the config.
*
* @param stdClass|array<string, mixed>|null $data Data to submit. Assoc array is supported since 9.1.0.
*/
public function submit(string $topic, ?string $userId = null, stdClass|array|null $data = null): void
{
if (!$this->configDataProvider->isEnabled()) {
return;
}
if (!$data) {
$data = (object) [];
}
if (is_array($data)) {
$data = (object) $data;
}
if ($userId) {
$data->userId = $userId;
}
$data->topicId = $topic;
$message = Json::encode($data);
try {
$this->sender->send($message);
} catch (Throwable $e) {
$this->log->error("WebSocketSubmission: " . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use React\EventLoop\LoopInterface;
/**
* Subscribes messages to Pusher::onMessageReceive method.
*/
interface Subscriber
{
/**
* Subscribe messages to Pusher::onMessageReceive method.
*/
public function subscribe(Pusher $pusher, LoopInterface $loop): void;
}

View File

@@ -0,0 +1,64 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Core\Binding\Factory;
use RuntimeException;
/**
* @implements Factory<Subscriber>
*/
class SubscriberFactory implements Factory
{
private const DEFAULT_MESSAGER = 'ZeroMQ';
public function __construct(
private InjectableFactory $injectableFactory,
private ConfigDataProvider $config,
private Metadata $metadata,
) {}
public function create(): Subscriber
{
$messager = $this->config->getMessager() ?? self::DEFAULT_MESSAGER;
/** @var ?class-string<Subscriber> $className */
$className = $this->metadata->get(['app', 'webSocket', 'messagers', $messager, 'subscriberClassName']);
if (!$className) {
throw new RuntimeException("No subscriber for messager '$messager'.");
}
return $this->injectableFactory->create($className);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\Utils\Config;
use ZMQContext;
use ZMQ;
class ZeroMQSender implements Sender
{
private const DSN = 'tcp://localhost:5555';
public function __construct(private Config $config)
{}
public function send(string $message): void
{
$dsn = $this->config->get('webSocketZeroMQSubmissionDsn') ?? self::DSN;
$context = new ZMQContext();
$socket = $context->getSocket(ZMQ::SOCKET_PUSH, 'my pusher');
$socket->connect($dsn);
$socket->send($message);
$socket->setSockOpt(ZMQ::SOCKOPT_LINGER, 1000);
$socket->disconnect($dsn);
}
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\WebSocket;
use Espo\Core\Utils\Config;
use React\EventLoop\LoopInterface;
use React\ZMQ\Context as ZMQContext;
use Evenement\EventEmitter;
use React\ZMQ\SocketWrapper;
use ZMQ;
class ZeroMQSubscriber implements Subscriber
{
private const DSN = 'tcp://127.0.0.1:5555';
public function __construct(private Config $config)
{}
public function subscribe(Pusher $pusher, LoopInterface $loop): void
{
$dsn = $this->config->get('webSocketZeroMQSubscriberDsn') ?? self::DSN;
$context = new ZMQContext($loop);
/** @var EventEmitter $pull */
/** @var SocketWrapper $pull */
$pull = $context->getSocket(ZMQ::SOCKET_PULL);
$pull->bind($dsn);
$pull->on('message', [$pusher, 'onMessageReceive']);
}
}