Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ appstore:
3rdparty/onlyoffice/documentserver:
mkdir -p 3rdparty/onlyoffice
mkdir -p oo-extract
curl -sLO https://github.com/ONLYOFFICE/DocumentServer/releases/download/v7.2.2/onlyoffice-documentserver.x86_64.rpm
curl -sLO https://github.com/ONLYOFFICE/DocumentServer/releases/download/v7.3.3/onlyoffice-documentserver.x86_64.rpm
cd oo-extract && rpm2cpio ../onlyoffice-documentserver.x86_64.rpm | cpio -idm
chmod -R 777 oo-extract/
cp -r oo-extract/var/www/onlyoffice/documentserver 3rdparty/onlyoffice
Expand All @@ -52,7 +52,7 @@ appstore:
--images="../../sdkjs/common/Images" \
--output-web="../../fonts" \
--selection="../FileConverter/bin/font_selection.bin"
sed -i 's/if(yb===d\[a\].ka)/if(d[a]\&\&yb===d[a].ka)/' 3rdparty/onlyoffice/documentserver/sdkjs/*/sdk-all.js
sed -i 's/if(yb===d\[a\].ka)/if(d[a]\&\&yb===d[a].ka)/' 3rdparty/onlyoffice/documentserver/sdkjs/*/sdk-all.js || true

version:
VERSION=$$(grep -ozP "DocsAPI\.DocEditor\.version\s*=\s*function\(\) *\{\n\s+return\s\'\K(\d+.\d+.\d+)" 3rdparty/onlyoffice/documentserver/web-apps/apps/api/documents/api.js) ;\
Expand Down
8 changes: 8 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

return [
'routes' => [
// Engine.IO / socket.io 4.x long-polling (sdkjs >= 7.3)
// Socket.IO client always appends a trailing slash to its path, so both variants are needed.
['name' => 'Document#socketIOPoll', 'url' => '/3rdparty/onlyoffice/documentserver/doc/{documentId}/c', 'verb' => 'GET'],
['name' => 'Document#socketIOPoll', 'url' => '/3rdparty/onlyoffice/documentserver/doc/{documentId}/c/', 'verb' => 'GET'],
['name' => 'Document#socketIOMessage', 'url' => '/3rdparty/onlyoffice/documentserver/doc/{documentId}/c', 'verb' => 'POST'],
['name' => 'Document#socketIOMessage', 'url' => '/3rdparty/onlyoffice/documentserver/doc/{documentId}/c/', 'verb' => 'POST'],

// SockJS long-polling (sdkjs <= 7.2, kept for compatibility)
['name' => 'Document#info', 'url' => '/3rdparty/onlyoffice/documentserver/doc/{documentId}/c/info', 'verb' => 'GET'],
['name' => 'Document#xhr', 'url' => '/3rdparty/onlyoffice/documentserver/doc/{documentId}/c/{serverId}/{sessionId}/xhr', 'verb' => 'POST'],
['name' => 'Document#xhrSend', 'url' => '/3rdparty/onlyoffice/documentserver/doc/{documentId}/c/{serverId}/{sessionId}/xhr_send', 'verb' => 'POST'],
Expand Down
2 changes: 1 addition & 1 deletion lib/Channel/ChannelFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function __construct(IIPCFactory $ipcFactory, SessionManager $sessionMana
}

public function getSession(string $sessionId, string $documentId, CommandDispatcher $commandDispatcher, array $initialResponses = []): Channel {
$key = "session_$sessionId";
$key = $sessionId;

return new Channel(
$sessionId,
Expand Down
2 changes: 1 addition & 1 deletion lib/Channel/IPCMulticast.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function pushMessage(string $message) {
$allSessions = $this->sessionManager->getSessionsForDocument($this->documentId);
foreach ($allSessions as $session) {
if ($session->getSessionId() !== $this->sessionId) {
$channel = $this->ipcFactory->getChannel("session_" . $session->getSessionId());
$channel = $this->ipcFactory->getChannel($session->getSessionId());
$channel->pushMessage($message);
}
}
Expand Down
24 changes: 13 additions & 11 deletions lib/Channel/SessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

namespace OCA\DocumentServer\Channel;

use OCA\DocumentServer\DB\QueryHelper;
use OCA\DocumentServer\IPC\IIPCFactory;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\QueryBuilder\IQueryBuilder;
Expand All @@ -45,7 +46,7 @@ public function getSessionCount(): int {

$query->select($query->func()->count())
->from('documentserver_sess');
return (int)$query->execute()->fetchColumn();
return (int)QueryHelper::fetchOne($query);
}

public function getSession(string $sessionId): ?Session {
Expand All @@ -55,7 +56,7 @@ public function getSession(string $sessionId): ?Session {
->from('documentserver_sess')
->where($query->expr()->eq('session_id', $query->createNamedParameter($sessionId)));

$row = $query->execute()->fetch();
$row = QueryHelper::fetchRow($query);
if ($row) {
return Session::fromRow($row);
} else {
Expand All @@ -70,7 +71,8 @@ private function getNextUserIndex(int $documentId): int {
->from('documentserver_sess')
->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT)));

return $query->execute()->fetchColumn() + 1;
$index = QueryHelper::fetchOne($query);
return ($index === false || $index === null) ? 1 : (int)$index + 1;
}

public function newSession(string $sessionId, int $documentId) {
Expand All @@ -90,7 +92,7 @@ public function newSession(string $sessionId, int $documentId) {
'readonly' => $query->createNamedParameter(1, \PDO::PARAM_INT),
'user_index' => $query->createNamedParameter($userId, \PDO::PARAM_INT),
]);
$query->execute();
QueryHelper::executeStatement($query);
}

public function authenticate(Session $session, string $user, string $userOriginal, string $userName, bool $readOnly): Session {
Expand All @@ -104,7 +106,7 @@ public function authenticate(Session $session, string $user, string $userOrigina
->set('username', $query->createNamedParameter($userName))
->set('readonly', $query->createNamedParameter($readOnly, \PDO::PARAM_INT))
->where($query->expr()->eq('session_id', $query->createNamedParameter($session->getSessionId())));
$query->execute();
QueryHelper::executeStatement($query);

return new Session(
$session->getSessionId(),
Expand All @@ -124,7 +126,7 @@ public function markAsSeen(string $sessionId) {
$query->update('documentserver_sess')
->set('last_seen', $query->createNamedParameter($this->timeFactory->getTime(), \PDO::PARAM_INT))
->where($query->expr()->eq('session_id', $query->createNamedParameter($sessionId)));
$query->execute();
QueryHelper::executeStatement($query);
}

private function getExpiredSessions(): array {
Expand All @@ -136,21 +138,21 @@ private function getExpiredSessions(): array {
$query->select('session_id')
->from('documentserver_sess')
->where($query->expr()->lt('last_seen', $query->createNamedParameter($cutoffTime, \PDO::PARAM_INT)));
return $query->execute()->fetchAll(\PDO::FETCH_COLUMN);
return QueryHelper::fetchFirstColumn($query);
}

public function cleanSessions(): int {
$expiredSessions = $this->getExpiredSessions();

foreach ($expiredSessions as $expiredSession) {
$this->ipcFactory->cleanupChannel("session_$expiredSession");
$this->ipcFactory->cleanupChannel($expiredSession);
}

$query = $this->connection->getQueryBuilder();

$query->delete('documentserver_sess')
->where($query->expr()->in('session_id', $query->createNamedParameter($expiredSessions, IQueryBuilder::PARAM_STR_ARRAY)));
return $query->execute();
return QueryHelper::executeStatement($query);
}

public function isDocumentActive(int $documentId): bool {
Expand All @@ -170,7 +172,7 @@ public function getSessionsForDocument(int $documentId): array {

return array_map(function (array $row) {
return Session::fromRow($row);
}, $query->execute()->fetchAll());
}, QueryHelper::fetchAll($query));
}

public function getSessionForUser(string $userId): ?Session {
Expand All @@ -180,7 +182,7 @@ public function getSessionForUser(string $userId): ?Session {
->from('documentserver_sess')
->where($query->expr()->eq($query->func()->concat('user', 'user_index'), $query->createNamedParameter($userId)));

$row = $query->execute()->fetch();
$row = QueryHelper::fetchRow($query);
if ($row) {
return Session::fromRow($row);
} else {
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/CoAuthoringController.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function command(string $c) {
$webVersion = new WebVersion();
return new DataResponse(['version' => $webVersion->getWebUIVersion(), 'error' => 0]);
default:
return ['error' => 5];
return new DataResponse(['error' => 5]);
}
}
}
2 changes: 1 addition & 1 deletion lib/Controller/ConvertController.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public function convert(bool $async, string $url, string $outputtype, string $fi

$url = $this->urlGenerator->linkToRouteAbsolute(
'documentserver_community.Document.documentFile', [
'path' => '/convert.' . $outputtype,
'path' => 'convert.' . $outputtype,
'docId' => $documentId,
]
);
Expand Down
167 changes: 165 additions & 2 deletions lib/Controller/DocumentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@

namespace OCA\DocumentServer\Controller;

use OCA\DocumentServer\Channel\Channel;
use OCA\DocumentServer\Channel\SessionManager;
use OCA\DocumentServer\EngineIOResponse;
use OCA\DocumentServer\FileResponse;
use OCA\DocumentServer\IPC\IIPCFactory;
use OCA\DocumentServer\OnlyOffice\URLDecoder;
Expand All @@ -43,9 +45,11 @@
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Response;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;

class DocumentController extends SessionController {
public const COMMAND_HANDLERS = [
Expand All @@ -70,6 +74,7 @@ class DocumentController extends SessionController {
private $webVersion;
private $ipcFactory;
private $sessionManager;
private LoggerInterface $logger;

public function __construct(
$appName,
Expand All @@ -81,7 +86,8 @@ public function __construct(
IURLGenerator $urlGenerator,
WebVersion $webVersion,
IIPCFactory $ipcFactory,
SessionManager $sessionManager
SessionManager $sessionManager,
LoggerInterface $logger
) {
parent::__construct($appName, $request, $sessionFactory, $random);

Expand All @@ -91,6 +97,7 @@ public function __construct(
$this->webVersion = $webVersion;
$this->ipcFactory = $ipcFactory;
$this->sessionManager = $sessionManager;
$this->logger = $logger;
}

protected function getInitialResponses(): array {
Expand Down Expand Up @@ -130,6 +137,7 @@ public function healthCheck() {
#[NoCSRFRequired]
#[PublicPage]
public function documentFile(int $docId, string $path, ?bool $download) {
$path = ltrim($path, '/');
$file = $this->documentStore->openDocumentFile($docId, $path);

$response = new FileResponse(
Expand Down Expand Up @@ -178,7 +186,7 @@ public function download(int $docId, string $cmd) {

$session = $this->sessionManager->getSessionForUser($cmd['userconnectionid']);
if ($session) {
$key = $key = "session_" . $session->getSessionId();
$key = $session->getSessionId();
$sessionChannel = $this->ipcFactory->getChannel($key);

$url = $this->urlGenerator->linkToRouteAbsolute(
Expand All @@ -205,4 +213,159 @@ public function download(int $docId, string $cmd) {
'data' => $docId,
]);
}

/**
* Engine.IO v4 long-poll GET handler (socket.io 4.x transport).
*
* Without ?sid: returns the EIO handshake (open packet).
* With ?sid: long-polls for the next queued message and encodes it
* as a socket.io EVENT or returns a noop on timeout.
*
* TYPE_OPEN is mapped to the socket.io CONNECT ack (packet "40"),
* which fires the client-side "connect" event.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
public function socketIOPoll(string $documentId): Response {
$sid = $this->request->getParam('sid');
$transport = $this->request->getParam('transport');

// PHP cannot upgrade to WebSocket; return 400 so the client
// falls back to HTTP long-polling on its own.
if ($transport === 'websocket') {
return new DataResponse(null, 400);
}

if (!$sid) {
// Initial EIO handshake — generate a fresh session ID.
// 8 bytes → 16 hex chars fits the existing VARCHAR(16) session_id column.
$sid = bin2hex(random_bytes(8));
return new EngineIOResponse('0' . json_encode([
'sid' => $sid,
'upgrades' => [], // no WebSocket upgrade in PHP
'pingInterval' => 25000,
'pingTimeout' => 20000,
'maxPayload' => 100000000,
]));
}

$channel = $this->sessionFactory->getSession(
$sid,
$documentId,
$this->getCommandDispatcher(),
$this->getInitialResponses()
);
[$type, $data] = $channel->getResponse();

switch ($type) {
case Channel::TYPE_OPEN:
// Socket.IO 4.x client requires {"sid":"..."} in the CONNECT ack.
// Without it the client fires connect_error ("v2.x server") instead
// of connect, so the sdkjs auth command is never sent.
return new EngineIOResponse('40' . json_encode(['sid' => $sid]));

case Channel::TYPE_ARRAY:
// Wrap as socket.io EVENT: 4=EIO message, 2=sio event.
// sdkjs >= 7.3 uses Socket.IO 4.x which delivers the argument
// as a plain object, not a JSON string, so pass $data directly.
return new EngineIOResponse('42' . json_encode(['message', $data]));

case Channel::TYPE_CLOSE:
return new EngineIOResponse('41'); // socket.io DISCONNECT

case Channel::TYPE_HEARTBEAT:
// EIO v4 reversed ping direction: server sends PING (2), client
// responds PONG (3). Without this, the client's pingTimeoutTimer
// fires after pingInterval+pingTimeout (45 s) and disconnects.
return new EngineIOResponse('2');

default:
return new EngineIOResponse('6'); // EIO noop (fallback)
}
}

/**
* Engine.IO v4 long-poll POST handler (socket.io 4.x transport).
*
* Accepts one or more EIO packets separated by 0x1e (Record Separator).
* Relevant packet types:
* 40 socket.io CONNECT (no-op here; session is lazily created on GET)
* 42 socket.io EVENT (dispatched to command handlers)
* 41 socket.io DISCONNECT (ignored; idle handler cleans up)
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
public function socketIOMessage(string $documentId): Response {
$sid = $this->request->getParam('sid');
if (!$sid) {
return new EngineIOResponse('ok');
}

$body = (string)file_get_contents('php://input');

// EIO v4 allows multiple packets in one POST, delimited by 0x1e.
foreach (explode("\x1e", $body) as $packet) {
if ($packet !== '') {
$this->handleEngineIOPacket($packet, $sid, $documentId);
}
}

return new EngineIOResponse('ok');
}

private function handleEngineIOPacket(string $packet, string $sid, string $documentId): void {
// Packet must be at least "4X" (EIO MESSAGE + sio type).
if (strlen($packet) < 2 || $packet[0] !== '4') {
return;
}

$sioType = $packet[1];
$payload = substr($packet, 2);

if ($sioType === '2') {
// socket.io EVENT: payload is JSON array ["eventName", ...args]
$event = json_decode($payload, true);
if (!is_array($event) || count($event) < 2 || $event[0] !== 'message') {
return;
}

$rawCommand = $event[1];

// sdkjs may pass the command as a pre-serialised JSON string (legacy
// SockJS compatibility layer) or as a plain object (native Socket.IO 4.x).
if (is_string($rawCommand)) {
$command = json_decode($rawCommand, true);
} else {
$command = $rawCommand;
}

if (!is_array($command)) {
$this->logger->debug('documentserver socketIO unrecognised command payload: {raw}', [
'raw' => json_encode($rawCommand),
]);
return;
}

try {
$this->logger->debug('documentserver socketIO command: type={type} keys={keys}', [
'type' => $command['type'] ?? '?',
'keys' => implode(',', array_keys($command)),
]);
$channel = $this->sessionFactory->getSession(
$sid,
$documentId,
$this->getCommandDispatcher()
);
$channel->handleCommand($command);
} catch (\Exception $e) {
$this->logger->warning('documentserver socketIO command error: {error}', [
'error' => $e->getMessage(),
'exception' => $e,
]);
}
}
// sio types '0' (CONNECT) and '1' (DISCONNECT) require no action here.
}
}
Loading