diff --git a/Makefile b/Makefile index 9e8892b..dd7e3fd 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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) ;\ diff --git a/appinfo/routes.php b/appinfo/routes.php index d136a92..1362f99 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -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'], diff --git a/lib/Channel/ChannelFactory.php b/lib/Channel/ChannelFactory.php index 8c8bfc5..05320d8 100644 --- a/lib/Channel/ChannelFactory.php +++ b/lib/Channel/ChannelFactory.php @@ -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, diff --git a/lib/Channel/IPCMulticast.php b/lib/Channel/IPCMulticast.php index 30acad0..ab263a4 100644 --- a/lib/Channel/IPCMulticast.php +++ b/lib/Channel/IPCMulticast.php @@ -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); } } diff --git a/lib/Channel/SessionManager.php b/lib/Channel/SessionManager.php index fc2c5b3..990898f 100644 --- a/lib/Channel/SessionManager.php +++ b/lib/Channel/SessionManager.php @@ -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; @@ -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 { @@ -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 { @@ -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) { @@ -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 { @@ -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(), @@ -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 { @@ -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 { @@ -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 { @@ -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 { diff --git a/lib/Controller/CoAuthoringController.php b/lib/Controller/CoAuthoringController.php index fd42fbb..d25375b 100644 --- a/lib/Controller/CoAuthoringController.php +++ b/lib/Controller/CoAuthoringController.php @@ -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]); } } } diff --git a/lib/Controller/ConvertController.php b/lib/Controller/ConvertController.php index 5d3511c..010c9cc 100644 --- a/lib/Controller/ConvertController.php +++ b/lib/Controller/ConvertController.php @@ -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, ] ); diff --git a/lib/Controller/DocumentController.php b/lib/Controller/DocumentController.php index 6007df6..aca3631 100644 --- a/lib/Controller/DocumentController.php +++ b/lib/Controller/DocumentController.php @@ -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; @@ -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 = [ @@ -70,6 +74,7 @@ class DocumentController extends SessionController { private $webVersion; private $ipcFactory; private $sessionManager; + private LoggerInterface $logger; public function __construct( $appName, @@ -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); @@ -91,6 +97,7 @@ public function __construct( $this->webVersion = $webVersion; $this->ipcFactory = $ipcFactory; $this->sessionManager = $sessionManager; + $this->logger = $logger; } protected function getInitialResponses(): array { @@ -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( @@ -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( @@ -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. + } } diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index ac1b081..9077c47 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -27,6 +27,10 @@ use OCA\DocumentServer\Channel\ChannelFactory; use OCA\DocumentServer\XHRResponse; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\IRequest; use OCP\Security\ISecureRandom; @@ -66,18 +70,16 @@ protected function getIdleHandlers(): array { }, $this->getIdleHandlerClasses()); } - /** - * @NoAdminRequired - * @NoCSRFRequired - * @PublicPage - */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] public function info(?string $version, string $documentId) { - return [ + return new DataResponse([ 'websocket' => false, 'origins' => ['*:*'], 'cookie_needed' => false, 'entropy' => (int)$this->random->generate(10, ISecureRandom::CHAR_DIGITS), - ]; + ]); } protected function getCommandDispatcher() { @@ -91,11 +93,9 @@ protected function getCommandDispatcher() { return $dispatcher; } - /** - * @NoAdminRequired - * @NoCSRFRequired - * @PublicPage - */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] public function xhr(?string $version, string $documentId, string $serverId, string $sessionId) { $session = $this->sessionFactory->getSession($sessionId, $documentId, $this->getCommandDispatcher(), $this->getInitialResponses()); [$type, $data] = $session->getResponse(); @@ -103,11 +103,9 @@ public function xhr(?string $version, string $documentId, string $serverId, stri return new XHRResponse($type, $data); } - /** - * @NoAdminRequired - * @NoCSRFRequired - * @PublicPage - */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] public function xhrSend(?string $version, string $documentId, string $serverId, string $sessionId) { $commands = json_decode(file_get_contents('php://input')); $session = $this->sessionFactory->getSession($sessionId, $documentId, $this->getCommandDispatcher()); @@ -115,5 +113,7 @@ public function xhrSend(?string $version, string $documentId, string $serverId, $command = json_decode($encodedCommand, true); $session->handleCommand($command); } + + return new DataResponse(null); } } diff --git a/lib/Controller/StaticController.php b/lib/Controller/StaticController.php index a902fb8..a39e2ea 100644 --- a/lib/Controller/StaticController.php +++ b/lib/Controller/StaticController.php @@ -30,6 +30,10 @@ use OCA\DocumentServer\FileResponse; use OCA\DocumentServer\SetupCheck; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\NotFoundResponse; use OCP\Files\IMimeTypeDetector; use OCP\IRequest; @@ -56,11 +60,9 @@ public function __construct( $this->sessionManager = $sessionManager; } - /** - * @NoCSRFRequired - * @NoAdminRequired - * @PublicPage - */ + #[NoCSRFRequired] + #[NoAdminRequired] + #[PublicPage] public function thirdparty(string $path) { if (strpos($path, '..') !== false) { throw new ForbiddenException(); @@ -74,11 +76,9 @@ public function thirdparty(string $path) { return $this->createFileResponse($localPath); } - /** - * @NoCSRFRequired - * @NoAdminRequired - * @PublicPage - */ + #[NoCSRFRequired] + #[NoAdminRequired] + #[PublicPage] public function webApps(string $path) { if (strpos($path, '..') !== false) { throw new ForbiddenException(); @@ -148,12 +148,10 @@ private function addScriptNonce(string $content, string $nonce): string { return str_replace('. + * + */ + +namespace OCA\DocumentServer\DB; + +use OCP\DB\QueryBuilder\IQueryBuilder; + +/** + * Compatibility shim for the Nextcloud DB query API. + * NC 28+ split IQueryBuilder::execute() into executeQuery() (SELECT) and executeStatement() (INSERT/UPDATE/DELETE). + * Each method falls back to the legacy execute() for older NC versions. + */ +class QueryHelper { + public static function fetchOne(IQueryBuilder $query) { + if (method_exists($query, 'executeQuery')) { + return $query->executeQuery()->fetchOne(); + } + + return $query->execute()->fetchColumn(); + } + + public static function fetchRow(IQueryBuilder $query) { + if (method_exists($query, 'executeQuery')) { + return $query->executeQuery()->fetchAssociative(); + } + + return $query->execute()->fetch(); + } + + public static function fetchAll(IQueryBuilder $query): array { + if (method_exists($query, 'executeQuery')) { + return $query->executeQuery()->fetchAllAssociative(); + } + + return $query->execute()->fetchAll(); + } + + public static function fetchFirstColumn(IQueryBuilder $query): array { + if (method_exists($query, 'executeQuery')) { + return $query->executeQuery()->fetchFirstColumn(); + } + + return $query->execute()->fetchAll(\PDO::FETCH_COLUMN); + } + + public static function executeStatement(IQueryBuilder $query): int { + if (method_exists($query, 'executeStatement')) { + return $query->executeStatement(); + } + + return $query->execute(); + } +} diff --git a/lib/Document/ChangeStore.php b/lib/Document/ChangeStore.php index c179ba8..eab456b 100644 --- a/lib/Document/ChangeStore.php +++ b/lib/Document/ChangeStore.php @@ -23,6 +23,7 @@ namespace OCA\DocumentServer\Document; +use OCA\DocumentServer\DB\QueryHelper; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IDBConnection; @@ -64,7 +65,7 @@ private function addChangeForDocument(int $documentId, string $change, string $u 'user' => $query->createNamedParameter($user), 'user_original' => $query->createNamedParameter($userOriginal), ]); - $query->execute(); + QueryHelper::executeStatement($query); } public function getMaxChangeIndexForDocument(int $documentId): int { @@ -73,9 +74,9 @@ public function getMaxChangeIndexForDocument(int $documentId): int { $query->select($query->func()->max('change_index')) ->from('documentserver_changes') ->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT))); - $index = $query->execute()->fetchColumn(); + $index = QueryHelper::fetchOne($query); - return ($index === false) ? -1 : (int)$index; + return ($index === false || $index === null) ? -1 : (int)$index; } public function getChangesForDocument(int $documentId): array { @@ -85,7 +86,7 @@ public function getChangesForDocument(int $documentId): array { ->from('documentserver_changes') ->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT))) ->orderBy('change_id', 'ASC'); - $rows = $query->execute()->fetchAll(); + $rows = QueryHelper::fetchAll($query); return array_map([Change::class, 'fromRow'], $rows); } @@ -95,7 +96,7 @@ public function getChangesAndMarkProcessingForDocument(int $documentId): array { $query->update('documentserver_changes') ->set('processing', $query->createNamedParameter(true, \PDO::PARAM_BOOL)) ->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT))); - $query->execute(); + QueryHelper::executeStatement($query); $query = $this->connection->getQueryBuilder(); @@ -104,7 +105,7 @@ public function getChangesAndMarkProcessingForDocument(int $documentId): array { ->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT))) ->andWhere($query->expr()->eq('processing', $query->createNamedParameter(true, \PDO::PARAM_INT))) ->orderBy('change_id', 'ASC'); - $rows = $query->execute()->fetchAll(); + $rows = QueryHelper::fetchAll($query); return array_map([Change::class, 'fromRow'], $rows); } @@ -114,7 +115,7 @@ public function unmarkProcessing(int $documentId) { $query->update('documentserver_changes') ->set('processing', $query->createNamedParameter(false, \PDO::PARAM_BOOL)) ->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT))); - $query->execute(); + QueryHelper::executeStatement($query); } public function deleteProcessedChanges(int $documentId) { @@ -123,7 +124,7 @@ public function deleteProcessedChanges(int $documentId) { $query->delete('documentserver_changes') ->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT))) ->andWhere($query->expr()->eq('processing', $query->createNamedParameter(true, \PDO::PARAM_INT))); - $query->execute(); + QueryHelper::executeStatement($query); } public function deleteChangesByIndex(int $documentId, int $changeIndex) { @@ -132,6 +133,6 @@ public function deleteChangesByIndex(int $documentId, int $changeIndex) { $query->delete('documentserver_changes') ->where($query->expr()->eq('document_id', $query->createNamedParameter($documentId, \PDO::PARAM_INT))) ->andWhere($query->expr()->gte('change_index', $query->createNamedParameter($changeIndex, \PDO::PARAM_INT))); - $query->execute(); + QueryHelper::executeStatement($query); } } diff --git a/lib/Document/LockStore.php b/lib/Document/LockStore.php index 5f0dcc5..2248016 100644 --- a/lib/Document/LockStore.php +++ b/lib/Document/LockStore.php @@ -23,6 +23,7 @@ namespace OCA\DocumentServer\Document; +use OCA\DocumentServer\DB\QueryHelper; use OCP\AppFramework\Utility\ITimeFactory; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -50,7 +51,7 @@ public function storeLock(int $document, string $user, $block) { "time" => $query->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT), "block" => $query->createNamedParameter(json_encode($block)), ]); - $query->execute(); + QueryHelper::executeStatement($query); } /** @@ -63,7 +64,7 @@ public function getLocksForDocument(int $document): array { ->from("documentserver_locks") ->where($query->expr()->eq("document_id", $query->createNamedParameter($document, IQueryBuilder::PARAM_INT))); - $rows = $query->execute()->fetchAll(); + $rows = QueryHelper::fetchAll($query); $locks = array_map([Lock::class, "fromRow"], $rows); $keys = array_map(function (Lock $lock, $key) { @@ -78,7 +79,7 @@ public function getLocksForDocument(int $document): array { } private function releaseLocksByQuery(IQueryBuilder $query): array { - $rows = $query->execute()->fetchAll(); + $rows = QueryHelper::fetchAll($query); $released = array_map([Lock::class, "fromRow"], $rows); @@ -90,7 +91,7 @@ private function releaseLocksByQuery(IQueryBuilder $query): array { $query->delete("documentserver_locks") ->where($query->expr()->in("lock_id", $query->createNamedParameter($lockIds, IQueryBuilder::PARAM_INT_ARRAY))); - $query->execute(); + QueryHelper::executeStatement($query); return $released; } @@ -128,6 +129,6 @@ public function expireLocks() { $query->delete("documentserver_locks") ->where($query->expr()->lt("time", $query->createNamedParameter($this->timeFactory->getTime() - self::TIMEOUT, IQueryBuilder::PARAM_INT))); - $query->execute(); + QueryHelper::executeStatement($query); } } diff --git a/lib/EngineIOResponse.php b/lib/EngineIOResponse.php new file mode 100644 index 0000000..9124abd --- /dev/null +++ b/lib/EngineIOResponse.php @@ -0,0 +1,38 @@ +packet = $packet; + $this->setStatus($statusCode); + $this->addHeader('Content-Type', 'text/plain; charset=UTF-8'); + $this->addHeader('Content-Length', (string)strlen($packet)); + } + + public function callback(IOutput $output): void { + if ($output->getHttpResponseCode() !== Http::STATUS_NOT_MODIFIED) { + print $this->packet; + } + } +} diff --git a/lib/IPC/DatabaseIPCBackend.php b/lib/IPC/DatabaseIPCBackend.php index e64f5b7..d09079f 100644 --- a/lib/IPC/DatabaseIPCBackend.php +++ b/lib/IPC/DatabaseIPCBackend.php @@ -23,6 +23,7 @@ namespace OCA\DocumentServer\IPC; +use OCA\DocumentServer\DB\QueryHelper; use OCP\AppFramework\Utility\ITimeFactory; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -46,7 +47,7 @@ public function cleanupChannel(string $channel) { $query = $this->connection->getQueryBuilder(); $query->delete('documentserver_ipc') ->where($query->expr()->eq('session_id', $query->createNamedParameter($channel))); - $query->execute(); + QueryHelper::executeStatement($query); } public function pushMessage(string $channel, string $message) { @@ -57,7 +58,7 @@ public function pushMessage(string $channel, string $message) { 'time' => $query->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT), 'message' => $query->createNamedParameter($message), ]); - $query->execute(); + QueryHelper::executeStatement($query); } public function popMessage(string $channel, int $timeout): ?string { @@ -68,11 +69,11 @@ public function popMessage(string $channel, int $timeout): ?string { ->orderBy('message_id', 'ASC') ->setMaxResults(1); - if ($row = $query->execute()->fetch()) { + if ($row = QueryHelper::fetchRow($query)) { $query = $this->connection->getQueryBuilder(); $query->delete('documentserver_ipc') ->where($query->expr()->eq('message_id', $query->createNamedParameter($row['message_id'], IQueryBuilder::PARAM_INT))); - $deleted = $query->execute(); + $deleted = QueryHelper::executeStatement($query); // if we didn't delete the row there was a race we lost, so we'll try again later if ($deleted === 1) { @@ -88,6 +89,6 @@ public function expireMessages() { $query = $this->connection->getQueryBuilder(); $query->delete('documentserver_ipc') ->where($query->expr()->lt('time', $query->createNamedParameter($this->timeFactory->getTime() - self::TIMEOUT, IQueryBuilder::PARAM_INT))); - $query->execute(); + QueryHelper::executeStatement($query); } } diff --git a/lib/OnlyOffice/AutoConfig.php b/lib/OnlyOffice/AutoConfig.php index 3f03913..2758f64 100644 --- a/lib/OnlyOffice/AutoConfig.php +++ b/lib/OnlyOffice/AutoConfig.php @@ -25,6 +25,34 @@ use OCP\IURLGenerator; class AutoConfig { + private const SUPPORTED_DEFAULT_FORMATS = [ + 'doc', + 'docx', + 'odp', + 'ods', + 'odt', + 'pdf', + 'ppt', + 'pptx', + 'xls', + 'xlsx', + ]; + + private const SUPPORTED_EDIT_FORMATS = [ + 'csv', + 'doc', + 'docx', + 'odp', + 'ods', + 'odt', + 'ppt', + 'pptx', + 'rtf', + 'txt', + 'xls', + 'xlsx', + ]; + private $urlGenerator; private $appConfig; @@ -36,6 +64,8 @@ public function __construct(IURLGenerator $urlGenerator, AppConfig $appConfig) { public function autoConfigIfNeeded() { if ($this->shouldAutoConfig()) { $this->autoConfig(); + } elseif ($this->isCommunityDocumentServerConfigured()) { + $this->syncSupportedFormats(false); } } @@ -48,6 +78,10 @@ private function shouldAutoConfig(): bool { return !$this->appConfig->GetDocumentServerUrl(); } + private function isCommunityDocumentServerConfigured(): bool { + return strpos((string)$this->appConfig->GetDocumentServerUrl(), 'apps/documentserver_community') !== false; + } + /** * Fill the documentserver url and other defaults */ @@ -56,29 +90,46 @@ private function autoConfig() { ['path' => '_']), 0, -strlen('/web-apps/_')); $this->appConfig->SetDocumentServerUrl($url); + $this->syncSupportedFormats(true); + $this->appConfig->SetSameTab(true); + } + + private function syncSupportedFormats(bool $forceWrite): void { $formatSettings = $this->appConfig->FormatsSetting(); - $defaultFormats = array_map(function ($settings) { - return $settings["def"] ?? false; - }, $formatSettings); - $editFormats = array_map(function ($settings) { - return $settings["edit"] ?? false; - }, $formatSettings); - - $defaultFormats = array_merge( - array_filter($defaultFormats, function ($settings) { - return $settings; - }), - ["odp" => true, "ods" => true, "odt" => true, "pptx" => true, "xlsx" => true, "docx" => true, "doc" => true, "ppt" => true, "xls" => true] - ); - $editFormats = array_merge( - array_filter($editFormats, function ($settings) { - return $settings; - }), - ["csv" => true, "odp" => true, "ods" => true, "odt" => true, "rtf" => true, "txt" => true] - ); + $defaultFormats = []; + $editFormats = []; + $hasUnsupportedFormats = false; + + foreach ($formatSettings as $format => $settings) { + if (!in_array($format, self::SUPPORTED_DEFAULT_FORMATS, true) && ($settings['def'] ?? false)) { + $hasUnsupportedFormats = true; + } + if (!in_array($format, self::SUPPORTED_EDIT_FORMATS, true) && ($settings['edit'] ?? false)) { + $hasUnsupportedFormats = true; + } + + $defaultFormats[$format] = in_array($format, self::SUPPORTED_DEFAULT_FORMATS, true) + && ($settings['def'] ?? false); + $editFormats[$format] = in_array($format, self::SUPPORTED_EDIT_FORMATS, true) + && ($settings['edit'] ?? false); + } + + if (!$forceWrite && !$hasUnsupportedFormats) { + return; + } + + // On initial config, enable all supported formats regardless of what FormatsSetting returns, + // so a fresh install does not end up with zero formats enabled. + if ($forceWrite) { + foreach (self::SUPPORTED_DEFAULT_FORMATS as $format) { + $defaultFormats[$format] = true; + } + foreach (self::SUPPORTED_EDIT_FORMATS as $format) { + $editFormats[$format] = true; + } + } $this->appConfig->SetDefaultFormats($defaultFormats); $this->appConfig->SetEditableFormats($editFormats); - $this->appConfig->SetSameTab(true); } } diff --git a/lib/OnlyOffice/WebVersion.php b/lib/OnlyOffice/WebVersion.php index 10c0888..563b259 100644 --- a/lib/OnlyOffice/WebVersion.php +++ b/lib/OnlyOffice/WebVersion.php @@ -25,6 +25,6 @@ class WebVersion { public function getWebUIVersion(): string { - return '7.2.2'; + return ''; } } diff --git a/tests/Channel/SessionManagerTest.php b/tests/Channel/SessionManagerTest.php index 876dfd7..50685aa 100644 --- a/tests/Channel/SessionManagerTest.php +++ b/tests/Channel/SessionManagerTest.php @@ -23,6 +23,8 @@ namespace OCA\DocumentServer\Tests\Channel; +use OCA\DocumentServer\DB\QueryHelper; +use OCA\DocumentServer\IPC\IIPCFactory; use OCA\DocumentServer\Channel\SessionManager; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IDBConnection; @@ -37,6 +39,8 @@ class SessionManagerTest extends TestCase { private $connection; /** @var ITimeFactory|MockObject */ private $timeFactory; + /** @var IIPCFactory|MockObject */ + private $ipcFactory; /** @var SessionManager */ private $manager; @@ -47,12 +51,13 @@ protected function setUp(): void { $this->connection = \OC::$server->getDatabaseConnection(); $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->ipcFactory = $this->createMock(IIPCFactory::class); $this->timeFactory->method('getTime') ->willReturnCallback(function () { return $this->time; }); - $this->manager = new SessionManager($this->connection, $this->timeFactory); + $this->manager = new SessionManager($this->connection, $this->timeFactory, $this->ipcFactory); } public function testNewGet() { @@ -84,7 +89,8 @@ public function testNewGet() { protected function tearDown(): void { $query = $this->connection->getQueryBuilder(); - $query->delete('documentserver_sess')->execute(); + $query->delete('documentserver_sess'); + QueryHelper::executeStatement($query); parent::tearDown(); }