PluginProbe
Gutenberg / 23.2.2
Gutenberg v23.2.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.2.2, at lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php

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