diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index 31444bb9a8b..02be670a6be 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -68,4 +68,5 @@ 'OCA\\Text\\Service\\SessionService' => $baseDir . '/../lib/Service/SessionService.php', 'OCA\\Text\\Service\\WorkspaceService' => $baseDir . '/../lib/Service/WorkspaceService.php', 'OCA\\Text\\TextFile' => $baseDir . '/../lib/TextFile.php', + 'OCA\\Text\\YjsMessage' => $baseDir . '/../lib/YjsMessage.php', ); diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index fc0f42009f1..0c5ec259a66 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -83,6 +83,7 @@ class ComposerStaticInitText 'OCA\\Text\\Service\\SessionService' => __DIR__ . '/..' . '/../lib/Service/SessionService.php', 'OCA\\Text\\Service\\WorkspaceService' => __DIR__ . '/..' . '/../lib/Service/WorkspaceService.php', 'OCA\\Text\\TextFile' => __DIR__ . '/..' . '/../lib/TextFile.php', + 'OCA\\Text\\YjsMessage' => __DIR__ . '/..' . '/../lib/YjsMessage.php', ); public static function getInitializer(ClassLoader $loader) diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index 53c52bb63c2..2bddccf5b9a 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -36,6 +36,7 @@ use OCA\Text\Db\StepMapper; use OCA\Text\Exception\DocumentHasUnsavedChangesException; use OCA\Text\Exception\DocumentSaveConflictException; +use OCA\Text\YjsMessage; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Constants; use OCP\DB\Exception; @@ -230,11 +231,16 @@ public function writeDocumentState(int $documentId, string $content): void { public function addStep(Document $document, Session $session, $steps, $version, $shareToken): array { $sessionId = $session->getId(); $documentId = $session->getDocumentId(); + $readOnly = $this->isReadOnly($this->getFileForSession($session, $shareToken), $shareToken); $stepsToInsert = []; $querySteps = []; $getStepsSinceVersion = null; $newVersion = $version; foreach ($steps as $step) { + $message = YjsMessage::fromBase64($step); + if ($readOnly && $message->isUpdate()) { + continue; + } // Steps are base64 encoded messages of the yjs protocols // https://github.com/yjs/y-protocols // Base64 encoded values smaller than "AAE" belong to sync step 1 messages. @@ -245,8 +251,8 @@ public function addStep(Document $document, Session $session, $steps, $version, array_push($stepsToInsert, $step); } } - if (sizeof($stepsToInsert) > 0) { - if ($this->isReadOnly($this->getFileForSession($session, $shareToken), $shareToken)) { + if (count($stepsToInsert) > 0) { + if ($readOnly) { throw new NotPermittedException('Read-only client tries to push steps with changes'); } $newVersion = $this->insertSteps($document, $session, $stepsToInsert, $version); diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index 69f22d6aaad..f357ed8486b 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -28,6 +28,7 @@ use OCA\Text\Db\Session; use OCA\Text\Db\SessionMapper; +use OCA\Text\YjsMessage; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\DirectEditing\IManager; @@ -245,6 +246,12 @@ public function updateSessionAwareness(Session $session, string $message): Sessi if (empty($message)) { return $session; } + + $decoded = YjsMessage::fromBase64($message); + if ($decoded->getYjsMessageType() !== YjsMessage::YJS_MESSAGE_AWARENESS) { + throw new \ValueError('Message passed was not an awareness message'); + } + $session->setLastAwarenessMessage($message); return $this->sessionMapper->update($session); } diff --git a/lib/YjsMessage.php b/lib/YjsMessage.php new file mode 100644 index 00000000000..54752270966 --- /dev/null +++ b/lib/YjsMessage.php @@ -0,0 +1,98 @@ +data)); + $num = 0; + $mult = 1; + $len = count($bytes); + while ($this->pos < $len) { + $r = $bytes[$this->pos++]; + // num = num | ((r & binary.BITS7) << len) + $num = $num + ($r & 0b1111111) * $mult; + $mult *= 128; + if ($r <= 0b1111111) { + return $num; + } + // Number.MAX_SAFE_INTEGER in JS + if ($num > 9007199254740990) { + throw new \OutOfBoundsException(); + } + } + throw new InvalidArgumentException(); + } + + public function getYjsMessageType(): int { + $oldPos = $this->pos; + $this->pos = 0; + $messageType = $this->readVarUint(); + $this->pos = $oldPos; + return $messageType; + } + + public function getYjsSyncType(): int { + $oldPos = $this->pos; + $this->pos = 0; + $messageType = $this->readVarUint(); + if ($messageType !== self::YJS_MESSAGE_SYNC) { + throw new \ValueError('Message is not a sync message'); + } + $syncType = $this->readVarUint(); + $this->pos = $oldPos; + return $syncType; + } + + /** + * Based on https://github.com/yjs/y-protocols/blob/master/PROTOCOL.md#handling-read-only-users + */ + public function isUpdate(): bool { + if ($this->getYjsMessageType() === self::YJS_MESSAGE_SYNC) { + if (in_array($this->getYjsSyncType(), [self::YJS_MESSAGE_SYNC_STEP2, self::YJS_MESSAGE_SYNC_UPDATE])) { + return true; + } + } + + return false; + } + +} diff --git a/tests/unit/YjsMessageTest.php b/tests/unit/YjsMessageTest.php new file mode 100644 index 00000000000..087aa6d5d77 --- /dev/null +++ b/tests/unit/YjsMessageTest.php @@ -0,0 +1,59 @@ +getYjsMessageType(); + self::assertEquals($type, $unpack1, 'type'); + if ($subtype !== null) { + $unpack2 = $buffer->getYjsSyncType(); + self::assertEquals($subtype, $unpack2); + } + } + + public function testV() { + self::assertEquals(0, YjsMessage::fromBase64('AA==')->readVarUint()); + self::assertEquals(127, YjsMessage::fromBase64('fw==')->readVarUint()); + self::assertEquals(128, YjsMessage::fromBase64('gAE=')->readVarUint()); + self::assertEquals(129, YjsMessage::fromBase64('gQE=')->readVarUint()); + self::assertEquals(259, YjsMessage::fromBase64('gwI=')->readVarUint()); + self::assertEquals(0, YjsMessage::fromBase64('AA==')->readVarUint()); + self::assertEquals(13372342, YjsMessage::fromBase64('tpewBg==')->readVarUint()); + self::assertEquals(1357913579, YjsMessage::fromBase64('67vAhwU=')->readVarUint()); + + $buffer = YjsMessage::fromBase64('tpewBg=='); + self::assertEquals(13372342, $buffer->readVarUint()); + } +}