Skip to content

Commit b98ff8b

Browse files
committed
chore: Basic backend y.js message decoder
Signed-off-by: Julius Härtl <jus@bitgrid.net>
1 parent 3dfaa7f commit b98ff8b

File tree

6 files changed

+143
-7
lines changed

6 files changed

+143
-7
lines changed

composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,5 @@
6161
'OCA\\Text\\Service\\SessionService' => $baseDir . '/../lib/Service/SessionService.php',
6262
'OCA\\Text\\Service\\WorkspaceService' => $baseDir . '/../lib/Service/WorkspaceService.php',
6363
'OCA\\Text\\TextFile' => $baseDir . '/../lib/TextFile.php',
64+
'OCA\\Text\\YjsMessage' => $baseDir . '/../lib/YjsMessage.php',
6465
);

composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class ComposerStaticInitText
7676
'OCA\\Text\\Service\\SessionService' => __DIR__ . '/..' . '/../lib/Service/SessionService.php',
7777
'OCA\\Text\\Service\\WorkspaceService' => __DIR__ . '/..' . '/../lib/Service/WorkspaceService.php',
7878
'OCA\\Text\\TextFile' => __DIR__ . '/..' . '/../lib/TextFile.php',
79+
'OCA\\Text\\YjsMessage' => __DIR__ . '/..' . '/../lib/YjsMessage.php',
7980
);
8081

8182
public static function getInitializer(ClassLoader $loader)

lib/Service/DocumentService.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use OCA\Text\Db\StepMapper;
3737
use OCA\Text\Exception\DocumentHasUnsavedChangesException;
3838
use OCA\Text\Exception\DocumentSaveConflictException;
39+
use OCA\Text\YjsMessage;
3940
use OCP\AppFramework\Db\DoesNotExistException;
4041
use OCP\AppFramework\Db\Entity;
4142
use OCP\Constants;
@@ -227,11 +228,9 @@ public function addStep($documentId, $sessionId, $steps, $version): array {
227228
$getStepsSinceVersion = null;
228229
$newVersion = $version;
229230
foreach ($steps as $step) {
230-
// Steps are base64 encoded messages of the yjs protocols
231-
// https://github.com/yjs/y-protocols
232-
// Base64 encoded values smaller than "AAE" belong to sync step 1 messages.
233-
// These messages query other participants for their current state.
234-
if ($step < "AAE") {
231+
$message = YjsMessage::fromBase64($step);
232+
// Filter out query steps as they would just trigger clients to send their steps again
233+
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) {
235234
array_push($querySteps, $step);
236235
} else {
237236
array_push($stepsToInsert, $step);
@@ -245,8 +244,9 @@ public function addStep($documentId, $sessionId, $steps, $version): array {
245244
$allSteps = $this->getSteps($documentId, $getStepsSinceVersion);
246245
$stepsToReturn = [];
247246
foreach ($allSteps as $step) {
248-
if ($step < "AAQ") {
249-
array_push($stepsToReturn, $step);
247+
$message = YjsMessage::fromBase64($step);
248+
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC) {
249+
$stepsToReturn[] = $step;
250250
}
251251
}
252252
return [

lib/Service/SessionService.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
use OCA\Text\Db\Session;
3030
use OCA\Text\Db\SessionMapper;
31+
use OCA\Text\YjsMessage;
3132
use OCP\AppFramework\Db\DoesNotExistException;
3233
use OCP\AppFramework\Utility\ITimeFactory;
3334
use OCP\DirectEditing\IManager;
@@ -240,6 +241,12 @@ public function updateSessionAwareness(int $documentId, int $sessionId, string $
240241
if (empty($message)) {
241242
return $session;
242243
}
244+
245+
$decoded = YjsMessage::fromBase64($message);
246+
if ($decoded->getYjsMessageType() !== YjsMessage::YJS_MESSAGE_AWARENESS) {
247+
throw new \ValueError('Message passed was not an awareness message');
248+
}
249+
243250
$session->setLastAwarenessMessage($message);
244251
return $this->sessionMapper->update($session);
245252
}

lib/YjsMessage.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace OCA\Text;
4+
5+
use InvalidArgumentException;
6+
7+
/**
8+
* Steps are base64 encoded messages of the yjs protocols
9+
* https://github.com/yjs/y-protocols
10+
*
11+
* This class is a simple representation of a message containing some methods
12+
* to decode parts of it for what we need on the backend
13+
*
14+
* Relevant resources:
15+
* https://github.com/yjs/y-protocols/blob/master/PROTOCOL.md
16+
* https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js#L19-L22
17+
* https://github.com/yjs/y-protocols/blob/master/sync.js#L38-L40
18+
* https://github.com/dmonad/lib0/blob/master/decoding.js
19+
*/
20+
class YjsMessage {
21+
22+
public const YJS_MESSAGE_SYNC = 0;
23+
public const YJS_MESSAGE_AWARENESS = 1;
24+
public const YJS_MESSAGE_AWARENESS_QUERY = 3;
25+
26+
public const YJS_MESSAGE_SYNC_STEP1 = 0;
27+
public const YJS_MESSAGE_SYNC_STEP2 = 1;
28+
public const YJS_MESSAGE_SYNC_UPDATE = 2;
29+
30+
private int $pos = 0;
31+
32+
public function __construct(
33+
private string $data = ''
34+
) {
35+
}
36+
37+
public static function fromBase64(string $data = ''): self {
38+
return new self(base64_decode($data));
39+
}
40+
41+
public function readVarUint(): int {
42+
$bytes = unpack('C*', substr($this->data, $this->pos, 4));
43+
$segments = [];
44+
$offset = 0;
45+
foreach ($bytes as $byte) {
46+
$offset++;
47+
$segments[] = (0x7f & $byte);
48+
49+
if (($byte & 0x80) === 0) {
50+
$integer = 0;
51+
foreach($segments as $segment) {
52+
$integer <<= 7;
53+
$integer |= (0x7f & $segment);
54+
}
55+
$this->pos += $offset;
56+
return $integer;
57+
}
58+
}
59+
if (($byte & 0x80) !== 0) {
60+
throw new InvalidArgumentException('Incomplete byte sequence.');
61+
}
62+
throw new InvalidArgumentException('Incomplete byte sequence.');
63+
}
64+
65+
public function getYjsMessageType(): int {
66+
$oldPos = $this->pos;
67+
$this->pos = 0;
68+
$messageType = $this->readVarUint();
69+
$this->pos = $oldPos;
70+
return $messageType;
71+
}
72+
73+
public function getYjsSyncType(): int {
74+
$oldPos = $this->pos;
75+
$this->pos = 0;
76+
$messageType = $this->readVarUint();
77+
if ($messageType !== self::YJS_MESSAGE_SYNC) {
78+
throw new \ValueError('Message is not a sync message');
79+
}
80+
$syncType = $this->readVarUint();
81+
$this->pos = $oldPos;
82+
return $syncType;
83+
}
84+
85+
}

tests/unit/YjsMessageTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace OCA\Text;
4+
5+
use Test\TestCase;
6+
7+
class YjsMessageTest extends TestCase {
8+
protected function setUp(): void {
9+
parent::setUp();
10+
}
11+
12+
// https://github.com/yjs/y-dat/blob/745d25f9690fceae5901d1225575fe8b6bcafdd7/src/y-dat.js#LL207C59-L210C1
13+
public function dataMessageTypes() {
14+
return [
15+
//
16+
['AAABAA==', 0, 0],
17+
// messageSync messageYjsSyncStep1
18+
['AAAyCIqghaQNBvrS2LoMB8L10I8KrQPD+t3RB2DcrYL4A40Ema2O4AMHz9bk8AIOtbm0PAE=', 0, 0],
19+
// messageAwareness
20+
['AQoBkZWK7gMAAnt9', 1, null],
21+
['AUIBwvXQjwqWAzl7InVzZXIiOnsibmFtZSI6ImFkbWluIiwiY29sb3IiOiIjZDA5ZTZkIn0sImN1cnNvciI6bnVsbH0=', 1, null],
22+
['AegBAcP63dEHtwHeAXsidXNlciI6eyJuYW1lIjoiR3Vlc3QiLCJjb2xvciI6IiM5M2IyN2IifSwiY3Vyc29yIjp7ImFuY2hvciI6eyJ0eXBlIjp7ImNsaWVudCI6MjcxNzEzNzYwMiwiY2xvY2siOjl9LCJ0bmFtZSI6bnVsbCwiaXRlbSI6bnVsbCwiYXNzb2MiOjB9LCJoZWFkIjp7InR5cGUiOnsiY2xpZW50IjoyNzE3MTM3NjAyLCJjbG9jayI6OX0sInRuYW1lIjpudWxsLCJpdGVtIjpudWxsLCJhc3NvYyI6MH19fQ==', 1, null],
23+
['AbsBAZGViu4DArIBeyJ1c2VyIjp7Im5hbWUiOiJHdWVzdCIsImNvbG9yIjoiI2I4YmU2OCJ9LCJjdXJzb3IiOnsiYW5jaG9yIjp7InR5cGUiOm51bGwsInRuYW1lIjoiZGVmYXVsdCIsIml0ZW0iOm51bGwsImFzc29jIjowfSwiaGVhZCI6eyJ0eXBlIjpudWxsLCJ0bmFtZSI6ImRlZmF1bHQiLCJpdGVtIjpudWxsLCJhc3NvYyI6MH19fQ==', 1, null],
24+
// messageSync messageYjsUpdate
25+
['AAISAQHD+t3RB2CEwvXQjwpHAWEA', 0, 2],
26+
['AAI0AQOKoIWkDQAHAQdkZWZhdWx0AwlwYXJhZ3JhcGgHAIqghaQNAAYEAIqghaQNAQR0ZXN0AA==', 0, 2],
27+
['AAIdAQGRlYruAx2okZWK7gMbAXcCaC0BkZWK7gMBGwE=', 0, 2],
28+
['AAIKAAGRlYruAwEVBA==', 0, 2],
29+
];
30+
}
31+
32+
/** @dataProvider dataMessageTypes */
33+
public function testMessageTypes($data, $type, $subtype) {
34+
$buffer = YjsMessage::fromBase64($data);
35+
$unpack1 = $buffer->getYjsMessageType();
36+
self::assertEquals($type, $unpack1, 'type');
37+
if ($subtype !== null) {
38+
$unpack2 = $buffer->getYjsSyncType();
39+
self::assertEquals($subtype, $unpack2);
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)