PluginProbe
Gutenberg / 23.6.2
Gutenberg v23.6.2
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / compat / wordpress-7.1 / class-wp-http-polling-sync-server.php

class-wp-http-polling-sync-server.php in Gutenberg 23.6.2, at lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php

540 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP_HTTP_Polling_Sync_Server class
4 *
5 * @package gutenberg
6 */
7
8 if ( ! class_exists( 'WP_Sync_Config' ) ) {
9 require_once __DIR__ . '/class-wp-sync-config.php';
10 }
11
12 if ( ! class_exists( 'WP_HTTP_Polling_Sync_Server' ) ) {
13
14 /**
15 * Core class that contains an HTTP server used for collaborative editing.
16 *
17 * @since 7.0.0
18 * @access private
19 */
20 class WP_HTTP_Polling_Sync_Server {
21 /**
22 * REST API namespace.
23 *
24 * @since 7.0.0
25 * @var string
26 */
27 const REST_NAMESPACE = 'wp-sync/v1';
28
29 /**
30 * Awareness timeout in seconds. Clients that haven't updated
31 * their awareness state within this time are considered disconnected.
32 *
33 * @since 7.0.0
34 * @var int
35 */
36 const AWARENESS_TIMEOUT = 30;
37
38 /**
39 * Threshold used to signal clients to send a compaction update.
40 *
41 * @since 7.0.0
42 * @var int
43 */
44 const COMPACTION_THRESHOLD = 50;
45
46 /**
47 * Maximum total size (in bytes) of the request body.
48 *
49 * @since 7.0.0
50 * @var int
51 */
52 const MAX_BODY_SIZE = 16 * MB_IN_BYTES;
53
54 /**
55 * Maximum number of rooms allowed per request.
56 *
57 * @since 7.0.0
58 * @var int
59 */
60 const MAX_ROOMS_PER_REQUEST = 50;
61
62 /**
63 * Maximum length of a single update data string.
64 *
65 * @since 7.0.0
66 * @var int
67 */
68 const MAX_UPDATE_DATA_SIZE = MB_IN_BYTES;
69
70 /**
71 * Sync update type: compaction.
72 *
73 * @since 7.0.0
74 * @var string
75 */
76 const UPDATE_TYPE_COMPACTION = 'compaction';
77
78 /**
79 * Sync update type: sync step 1.
80 *
81 * @since 7.0.0
82 * @var string
83 */
84 const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1';
85
86 /**
87 * Sync update type: sync step 2.
88 *
89 * @since 7.0.0
90 * @var string
91 */
92 const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2';
93
94 /**
95 * Sync update type: regular update.
96 *
97 * @since 7.0.0
98 * @var string
99 */
100 const UPDATE_TYPE_UPDATE = 'update';
101
102 /**
103 * Storage backend for sync updates.
104 *
105 * @since 7.0.0
106 */
107 private WP_Sync_Storage $storage;
108
109 /**
110 * Constructor.
111 *
112 * @since 7.0.0
113 *
114 * @param WP_Sync_Storage $storage Storage backend for sync updates.
115 */
116 public function __construct( WP_Sync_Storage $storage ) {
117 $this->storage = $storage;
118 }
119
120 /**
121 * Registers REST API routes.
122 *
123 * @since 7.0.0
124 */
125 public function register_routes(): void {
126 $typed_update_args = array(
127 'properties' => array(
128 'data' => array(
129 'type' => 'string',
130 'required' => true,
131 'maxLength' => self::MAX_UPDATE_DATA_SIZE,
132 ),
133 'type' => array(
134 'type' => 'string',
135 'required' => true,
136 'enum' => array(
137 self::UPDATE_TYPE_COMPACTION,
138 self::UPDATE_TYPE_SYNC_STEP1,
139 self::UPDATE_TYPE_SYNC_STEP2,
140 self::UPDATE_TYPE_UPDATE,
141 ),
142 ),
143 ),
144 'required' => true,
145 'type' => 'object',
146 );
147
148 $room_args = array(
149 'after' => array(
150 'minimum' => 0,
151 'required' => true,
152 'type' => 'integer',
153 ),
154 'awareness' => array(
155 'required' => true,
156 'type' => array( 'object', 'null' ),
157 ),
158 'client_id' => array(
159 'minimum' => 1,
160 'required' => true,
161 'type' => 'integer',
162 ),
163 'room' => array(
164 'required' => true,
165 'type' => 'string',
166 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$',
167 ),
168 'updates' => array(
169 'items' => $typed_update_args,
170 'minItems' => 0,
171 'required' => true,
172 'type' => 'array',
173 ),
174 );
175
176 register_rest_route(
177 self::REST_NAMESPACE,
178 '/updates',
179 array(
180 'methods' => array( WP_REST_Server::CREATABLE ),
181 'callback' => array( $this, 'handle_request' ),
182 'permission_callback' => array( $this, 'check_permissions' ),
183 'validate_callback' => array( $this, 'validate_request' ),
184 'args' => array(
185 'rooms' => array(
186 'items' => array(
187 'properties' => $room_args,
188 'type' => 'object',
189 ),
190 'maxItems' => self::MAX_ROOMS_PER_REQUEST,
191 'required' => true,
192 'type' => 'array',
193 ),
194 ),
195 )
196 );
197 }
198
199 /**
200 * Checks if the current user has permission to access a room.
201 *
202 * @since 7.0.0
203 *
204 * @param WP_REST_Request $request The REST request.
205 * @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
206 */
207 public function check_permissions( WP_REST_Request $request ) {
208 // Minimum cap check. Is user logged in with a contributor role or higher?
209 if ( ! current_user_can( 'edit_posts' ) ) {
210 return new WP_Error(
211 'rest_cannot_edit',
212 __( 'You do not have permission to perform this action', 'gutenberg' ),
213 array( 'status' => rest_authorization_required_code() )
214 );
215 }
216
217 $rooms = $request['rooms'];
218 $wp_user_id = get_current_user_id();
219 $forbidden_rooms = array();
220
221 foreach ( $rooms as $room ) {
222 $client_id = $room['client_id'];
223 $room = $room['room'];
224
225 // Check that the client_id is not already owned by another user.
226 $existing_awareness = $this->storage->get_awareness_state( $room );
227 foreach ( $existing_awareness as $entry ) {
228 if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) {
229 return new WP_Error(
230 'rest_cannot_edit',
231 __( 'Client ID is already in use by another user.', 'gutenberg' ),
232 array( 'status' => 403 )
233 );
234 }
235 }
236
237 $parsed_room = WP_Sync_Config::parse_room( $room );
238 if ( null === $parsed_room || ! WP_Sync_Config::can_user_sync_entity_type( $parsed_room['entity_kind'], $parsed_room['entity_name'], $parsed_room['object_id'] ) ) {
239 $forbidden_rooms[] = $room;
240 }
241 }
242
243 if ( ! empty( $forbidden_rooms ) ) {
244 return new WP_Error(
245 'rest_cannot_edit',
246 sprintf(
247 /* translators: %s: Comma-separated list of room names. */
248 __( 'You do not have permission to sync one or more entities: %s.', 'gutenberg' ),
249 implode( ', ', $forbidden_rooms )
250 ),
251 array(
252 'status' => rest_authorization_required_code(),
253 'rooms' => $forbidden_rooms,
254 )
255 );
256 }
257
258 return true;
259 }
260
261 /**
262 * Validates that the request body does not exceed the maximum allowed size.
263 *
264 * Runs as the route-level validate_callback, after per-arg schema
265 * validation has already passed.
266 *
267 * @since 7.0.0
268 *
269 * @param WP_REST_Request $request The REST request.
270 * @return true|WP_Error True if valid, WP_Error if the body is too large.
271 */
272 public function validate_request( WP_REST_Request $request ) {
273 $body = $request->get_body();
274 if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) {
275 return new WP_Error(
276 'rest_sync_body_too_large',
277 __( 'Request body is too large.', 'gutenberg' ),
278 array( 'status' => 413 )
279 );
280 }
281
282 return true;
283 }
284
285 /**
286 * Handles request: stores sync updates and awareness data, and returns
287 * updates the client is missing.
288 *
289 * @since 7.0.0
290 *
291 * @param WP_REST_Request $request The REST request.
292 * @return WP_REST_Response|WP_Error Response object or error.
293 */
294 public function handle_request( WP_REST_Request $request ) {
295 $rooms = $request['rooms'];
296 $response = array(
297 'rooms' => array(),
298 );
299
300 foreach ( $rooms as $room_request ) {
301 $awareness = $room_request['awareness'];
302 $client_id = $room_request['client_id'];
303 $cursor = $room_request['after'];
304 $room = $room_request['room'];
305
306 // Merge awareness state.
307 $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );
308
309 // The lowest client ID is nominated to perform compaction when needed.
310 $is_compactor = false;
311 if ( count( $merged_awareness ) > 0 ) {
312 $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
313 }
314
315 // Process each update according to its type.
316 foreach ( $room_request['updates'] as $update ) {
317 $result = $this->process_sync_update( $room, $client_id, $cursor, $update );
318 if ( is_wp_error( $result ) ) {
319 return $result;
320 }
321 }
322
323 // Get updates for this client.
324 $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor );
325 $room_response['awareness'] = $merged_awareness;
326
327 $response['rooms'][] = $room_response;
328 }
329
330 return new WP_REST_Response( $response, 200 );
331 }
332
333 /**
334 * Processes and stores an awareness update from a client.
335 *
336 * @since 7.0.0
337 *
338 * @param string $room Room identifier.
339 * @param int $client_id Client identifier.
340 * @param array<string, mixed>|null $awareness_update Awareness state sent by the client.
341 * @return array<int, array<string, mixed>> Map of client ID to awareness state.
342 */
343 private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
344 $existing_awareness = $this->storage->get_awareness_state( $room );
345 $updated_awareness = array();
346 $current_time = time();
347
348 foreach ( $existing_awareness as $entry ) {
349 // Remove this client's entry (it will be updated below).
350 if ( $client_id === $entry['client_id'] ) {
351 continue;
352 }
353
354 // Remove entries that have expired.
355 if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) {
356 continue;
357 }
358
359 $updated_awareness[] = $entry;
360 }
361
362 // Add this client's awareness state.
363 if ( null !== $awareness_update ) {
364 $updated_awareness[] = array(
365 'client_id' => $client_id,
366 'state' => $awareness_update,
367 'updated_at' => $current_time,
368 'wp_user_id' => get_current_user_id(),
369 );
370 }
371
372 // This action can fail, but it shouldn't fail the entire request.
373 $this->storage->set_awareness_state( $room, $updated_awareness );
374
375 // Convert to client_id => state map for response.
376 $response = array();
377 foreach ( $updated_awareness as $entry ) {
378 $response[ $entry['client_id'] ] = $entry['state'];
379 }
380
381 return $response;
382 }
383
384 /**
385 * Processes a sync update based on its type.
386 *
387 * @since 7.0.0
388 *
389 * @param string $room Room identifier.
390 * @param int $client_id Client identifier.
391 * @param int $cursor Client cursor (marker of last seen update).
392 * @param array{data: string, type: string} $update Sync update.
393 * @return true|WP_Error True on success, WP_Error on storage failure.
394 */
395 private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) {
396 $data = $update['data'];
397 $type = $update['type'];
398
399 switch ( $type ) {
400 case self::UPDATE_TYPE_COMPACTION:
401 /*
402 * Compaction replaces updates the client has already seen. Only remove
403 * updates with markers before the client's cursor to preserve updates
404 * that arrived since the client's last sync.
405 *
406 * Check for a newer compaction update first. If one exists, skip this
407 * compaction to avoid overwriting it.
408 */
409 $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
410 $has_newer_compaction = false;
411
412 foreach ( $updates_after_cursor as $existing ) {
413 if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) {
414 $has_newer_compaction = true;
415 break;
416 }
417 }
418
419 if ( ! $has_newer_compaction ) {
420 if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
421 return new WP_Error(
422 'rest_sync_storage_error',
423 __( 'Failed to remove updates during compaction.', 'gutenberg' ),
424 array( 'status' => 500 )
425 );
426 }
427
428 return $this->add_update( $room, $client_id, $type, $data );
429 }
430
431 /*
432 * A newer compaction already advanced the cursor, but we
433 * can not safely drop an update. The incoming bytes still encode
434 * operations other clients may not have seen, so store them as a
435 * regular update. Y.applyUpdateV2 merges state-as-update blobs
436 * idempotently, so overlap with the existing compaction is safe.
437 */
438 return $this->add_update( $room, $client_id, self::UPDATE_TYPE_UPDATE, $data );
439
440 case self::UPDATE_TYPE_SYNC_STEP1:
441 case self::UPDATE_TYPE_SYNC_STEP2:
442 case self::UPDATE_TYPE_UPDATE:
443 /*
444 * Sync step 1 announces a client's state vector. Other clients need
445 * to see it so they can respond with sync_step2 containing missing
446 * updates. The cursor-based filtering prevents re-delivery.
447 *
448 * Sync step 2 contains updates for a specific client.
449 *
450 * All updates are stored persistently.
451 */
452 return $this->add_update( $room, $client_id, $type, $data );
453 }
454
455 return new WP_Error(
456 'rest_invalid_update_type',
457 __( 'Invalid sync update type.', 'gutenberg' ),
458 array( 'status' => 400 )
459 );
460 }
461
462 /**
463 * Adds an update to a room's update list via storage.
464 *
465 * @since 7.0.0
466 *
467 * @param string $room Room identifier.
468 * @param int $client_id Client identifier.
469 * @param string $type Update type (sync_step1, sync_step2, update, compaction).
470 * @param string $data Base64-encoded update data.
471 * @return true|WP_Error True on success, WP_Error on storage failure.
472 */
473 private function add_update( string $room, int $client_id, string $type, string $data ) {
474 $update = array(
475 'client_id' => $client_id,
476 'data' => $data,
477 'type' => $type,
478 );
479
480 if ( ! $this->storage->add_update( $room, $update ) ) {
481 return new WP_Error(
482 'rest_sync_storage_error',
483 __( 'Failed to store sync update.', 'gutenberg' ),
484 array( 'status' => 500 )
485 );
486 }
487
488 return true;
489 }
490
491 /**
492 * Gets sync updates for a specific client from a room after a given cursor.
493 *
494 * Delegates cursor-based retrieval to the storage layer, then applies
495 * client-specific filtering and compaction logic.
496 *
497 * @since 7.0.0
498 *
499 * @param string $room Room identifier.
500 * @param int $client_id Client identifier.
501 * @param int $cursor Return updates after this cursor.
502 * @param bool $is_compactor True if this client is nominated to perform compaction.
503 * @return array{
504 * end_cursor: int,
505 * should_compact: bool,
506 * room: string,
507 * total_updates: int,
508 * updates: array<int, array{data: string, type: string}>,
509 * } Response data for this room.
510 */
511 private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array {
512 $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
513 $total_updates = $this->storage->get_update_count( $room );
514
515 // Filter out this client's updates, except compaction updates.
516 $typed_updates = array();
517 foreach ( $updates_after_cursor as $update ) {
518 if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) {
519 continue;
520 }
521
522 $typed_updates[] = array(
523 'data' => $update['data'],
524 'type' => $update['type'],
525 );
526 }
527
528 $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;
529
530 return array(
531 'end_cursor' => $this->storage->get_cursor( $room ),
532 'room' => $room,
533 'should_compact' => $should_compact,
534 'total_updates' => $total_updates,
535 'updates' => $typed_updates,
536 );
537 }
538 }
539 }
540