PluginProbe
Gutenberg / 22.7.0
Gutenberg v22.7.0
24.1.0 24.0.0 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 All 404 releases
gutenberg / lib / compat / wordpress-7.0 / class-wp-http-polling-sync-server.php

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

517 lines 14.9 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 * Sync update type: compaction.
44 *
45 * @since 7.0.0
46 * @var string
47 */
48 const UPDATE_TYPE_COMPACTION = 'compaction';
49
50 /**
51 * Sync update type: sync step 1.
52 *
53 * @since 7.0.0
54 * @var string
55 */
56 const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1';
57
58 /**
59 * Sync update type: sync step 2.
60 *
61 * @since 7.0.0
62 * @var string
63 */
64 const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2';
65
66 /**
67 * Sync update type: regular update.
68 *
69 * @since 7.0.0
70 * @var string
71 */
72 const UPDATE_TYPE_UPDATE = 'update';
73
74 /**
75 * Storage backend for sync updates.
76 *
77 * @since 7.0.0
78 */
79 private WP_Sync_Storage $storage;
80
81 /**
82 * Constructor.
83 *
84 * @since 7.0.0
85 *
86 * @param WP_Sync_Storage $storage Storage backend for sync updates.
87 */
88 public function __construct( WP_Sync_Storage $storage ) {
89 $this->storage = $storage;
90 }
91
92 /**
93 * Registers REST API routes.
94 *
95 * @since 7.0.0
96 */
97 public function register_routes(): void {
98 $typed_update_args = array(
99 'properties' => array(
100 'data' => array(
101 'type' => 'string',
102 'required' => true,
103 ),
104 'type' => array(
105 'type' => 'string',
106 'required' => true,
107 'enum' => array(
108 self::UPDATE_TYPE_COMPACTION,
109 self::UPDATE_TYPE_SYNC_STEP1,
110 self::UPDATE_TYPE_SYNC_STEP2,
111 self::UPDATE_TYPE_UPDATE,
112 ),
113 ),
114 ),
115 'required' => true,
116 'type' => 'object',
117 );
118
119 $room_args = array(
120 'after' => array(
121 'minimum' => 0,
122 'required' => true,
123 'type' => 'integer',
124 ),
125 'awareness' => array(
126 'required' => true,
127 'type' => array( 'object', 'null' ),
128 ),
129 'client_id' => array(
130 'minimum' => 1,
131 'required' => true,
132 'type' => 'integer',
133 ),
134 'room' => array(
135 'required' => true,
136 'type' => 'string',
137 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$',
138 ),
139 'updates' => array(
140 'items' => $typed_update_args,
141 'minItems' => 0,
142 'required' => true,
143 'type' => 'array',
144 ),
145 );
146
147 register_rest_route(
148 self::REST_NAMESPACE,
149 '/updates',
150 array(
151 'methods' => array( WP_REST_Server::CREATABLE ),
152 'callback' => array( $this, 'handle_request' ),
153 'permission_callback' => array( $this, 'check_permissions' ),
154 'args' => array(
155 'rooms' => array(
156 'items' => array(
157 'properties' => $room_args,
158 'type' => 'object',
159 ),
160 'required' => true,
161 'type' => 'array',
162 ),
163 ),
164 )
165 );
166 }
167
168 /**
169 * Checks if the current user has permission to access a room.
170 *
171 * @since 7.0.0
172 *
173 * @param WP_REST_Request $request The REST request.
174 * @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
175 */
176 public function check_permissions( WP_REST_Request $request ) {
177 // Minimum cap check. Is user logged in with a contributor role or higher?
178 if ( ! current_user_can( 'edit_posts' ) ) {
179 return new WP_Error(
180 'rest_cannot_edit',
181 __( 'You do not have permission to perform this action', 'gutenberg' ),
182 array( 'status' => rest_authorization_required_code() )
183 );
184 }
185
186 $rooms = $request['rooms'];
187
188 foreach ( $rooms as $room ) {
189 $room = $room['room'];
190 $type_parts = explode( '/', $room, 2 );
191 $object_parts = explode( ':', $type_parts[1] ?? '', 2 );
192
193 $entity_kind = $type_parts[0];
194 $entity_name = $object_parts[0];
195 $object_id = $object_parts[1] ?? null;
196
197 if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) {
198 return new WP_Error(
199 'rest_cannot_edit',
200 sprintf(
201 /* translators: %s: The room name encodes the current entity being synced. */
202 __( 'You do not have permission to sync this entity: %s.', 'gutenberg' ),
203 $room
204 ),
205 array( 'status' => rest_authorization_required_code() )
206 );
207 }
208 }
209
210 return true;
211 }
212
213 /**
214 * Handles request: stores sync updates and awareness data, and returns
215 * updates the client is missing.
216 *
217 * @since 7.0.0
218 *
219 * @param WP_REST_Request $request The REST request.
220 * @return WP_REST_Response|WP_Error Response object or error.
221 */
222 public function handle_request( WP_REST_Request $request ) {
223 $rooms = $request['rooms'];
224 $response = array(
225 'rooms' => array(),
226 );
227
228 foreach ( $rooms as $room_request ) {
229 $awareness = $room_request['awareness'];
230 $client_id = $room_request['client_id'];
231 $cursor = $room_request['after'];
232 $room = $room_request['room'];
233
234 // Merge awareness state.
235 $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );
236
237 // The lowest client ID is nominated to perform compaction when needed.
238 $is_compactor = false;
239 if ( count( $merged_awareness ) > 0 ) {
240 $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
241 }
242
243 // Process each update according to its type.
244 foreach ( $room_request['updates'] as $update ) {
245 $result = $this->process_sync_update( $room, $client_id, $cursor, $update );
246 if ( is_wp_error( $result ) ) {
247 return $result;
248 }
249 }
250
251 // Get updates for this client.
252 $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor );
253 $room_response['awareness'] = $merged_awareness;
254
255 $response['rooms'][] = $room_response;
256 }
257
258 return new WP_REST_Response( $response, 200 );
259 }
260
261 /**
262 * Checks if the current user can sync a specific entity type.
263 *
264 * @since 7.0.0
265 *
266 * @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'.
267 * @param string $entity_name The entity name, e.g. 'post', 'category', 'site'.
268 * @param string|null $object_id The object ID / entity key for single entities, null for collections.
269 * @return bool True if user has permission, otherwise false.
270 */
271 private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
272 // Handle single post type entities with a defined object ID.
273 if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) {
274 return current_user_can( 'edit_post', (int) $object_id );
275 }
276
277 // Handle single taxonomy term entities with a defined object ID.
278 if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) {
279 $taxonomy = get_taxonomy( $entity_name );
280 return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms );
281 }
282
283 // Handle single comment entities with a defined object ID.
284 if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) {
285 return current_user_can( 'edit_comment', (int) $object_id );
286 }
287
288 // All the remaining checks are for collections. If an object ID is provided,
289 // reject the request.
290 if ( null !== $object_id ) {
291 return false;
292 }
293
294 // For postType collections, check if the user can edit posts of this type.
295 if ( 'postType' === $entity_kind ) {
296 $post_type_object = get_post_type_object( $entity_name );
297 if ( ! isset( $post_type_object->cap->edit_posts ) ) {
298 return false;
299 }
300
301 return current_user_can( $post_type_object->cap->edit_posts );
302 }
303
304 // Collection syncing does not exchange entity data. It only signals if
305 // another user has updated an entity in the collection. Therefore, we only
306 // compare against an allow list of collection types.
307 $allowed_collection_entity_kinds = array(
308 'postType',
309 'root',
310 'taxonomy',
311 );
312
313 return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
314 }
315
316 /**
317 * Processes and stores an awareness update from a client.
318 *
319 * @since 7.0.0
320 *
321 * @param string $room Room identifier.
322 * @param int $client_id Client identifier.
323 * @param array<string, mixed>|null $awareness_update Awareness state sent by the client.
324 * @return array<int, array<string, mixed>> Map of client ID to awareness state.
325 */
326 private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
327 $existing_awareness = $this->storage->get_awareness_state( $room );
328 $updated_awareness = array();
329 $current_time = time();
330
331 foreach ( $existing_awareness as $entry ) {
332 // Remove this client's entry (it will be updated below).
333 if ( $client_id === $entry['client_id'] ) {
334 continue;
335 }
336
337 // Remove entries that have expired.
338 if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) {
339 continue;
340 }
341
342 $updated_awareness[] = $entry;
343 }
344
345 // Add this client's awareness state.
346 if ( null !== $awareness_update ) {
347 $updated_awareness[] = array(
348 'client_id' => $client_id,
349 'state' => $awareness_update,
350 'updated_at' => $current_time,
351 );
352 }
353
354 // This action can fail, but it shouldn't fail the entire request.
355 $this->storage->set_awareness_state( $room, $updated_awareness );
356
357 // Convert to client_id => state map for response.
358 $response = array();
359 foreach ( $updated_awareness as $entry ) {
360 $response[ $entry['client_id'] ] = $entry['state'];
361 }
362
363 return $response;
364 }
365
366 /**
367 * Processes a sync update based on its type.
368 *
369 * @since 7.0.0
370 *
371 * @param string $room Room identifier.
372 * @param int $client_id Client identifier.
373 * @param int $cursor Client cursor (marker of last seen update).
374 * @param array{data: string, type: string} $update Sync update.
375 * @return true|WP_Error True on success, WP_Error on storage failure.
376 */
377 private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) {
378 $data = $update['data'];
379 $type = $update['type'];
380
381 switch ( $type ) {
382 case self::UPDATE_TYPE_COMPACTION:
383 /*
384 * Compaction replaces updates the client has already seen. Only remove
385 * updates with markers before the client's cursor to preserve updates
386 * that arrived since the client's last sync.
387 *
388 * Check for a newer compaction update first. If one exists, skip this
389 * compaction to avoid overwriting it.
390 */
391 $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
392 $has_newer_compaction = false;
393
394 foreach ( $updates_after_cursor as $existing ) {
395 if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) {
396 $has_newer_compaction = true;
397 break;
398 }
399 }
400
401 if ( ! $has_newer_compaction ) {
402 if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
403 return new WP_Error(
404 'rest_sync_storage_error',
405 __( 'Failed to remove updates during compaction.', 'gutenberg' ),
406 array( 'status' => 500 )
407 );
408 }
409
410 return $this->add_update( $room, $client_id, $type, $data );
411 }
412
413 // Reaching this point means there's a newer compaction, so we can
414 // silently ignore this one.
415 return true;
416
417 case self::UPDATE_TYPE_SYNC_STEP1:
418 case self::UPDATE_TYPE_SYNC_STEP2:
419 case self::UPDATE_TYPE_UPDATE:
420 /*
421 * Sync step 1 announces a client's state vector. Other clients need
422 * to see it so they can respond with sync_step2 containing missing
423 * updates. The cursor-based filtering prevents re-delivery.
424 *
425 * Sync step 2 contains updates for a specific client.
426 *
427 * All updates are stored persistently.
428 */
429 return $this->add_update( $room, $client_id, $type, $data );
430 }
431
432 return new WP_Error(
433 'rest_invalid_update_type',
434 __( 'Invalid sync update type.', 'gutenberg' ),
435 array( 'status' => 400 )
436 );
437 }
438
439 /**
440 * Adds an update to a room's update list via storage.
441 *
442 * @since 7.0.0
443 *
444 * @param string $room Room identifier.
445 * @param int $client_id Client identifier.
446 * @param string $type Update type (sync_step1, sync_step2, update, compaction).
447 * @param string $data Base64-encoded update data.
448 * @return true|WP_Error True on success, WP_Error on storage failure.
449 */
450 private function add_update( string $room, int $client_id, string $type, string $data ) {
451 $update = array(
452 'client_id' => $client_id,
453 'data' => $data,
454 'type' => $type,
455 );
456
457 if ( ! $this->storage->add_update( $room, $update ) ) {
458 return new WP_Error(
459 'rest_sync_storage_error',
460 __( 'Failed to store sync update.', 'gutenberg' ),
461 array( 'status' => 500 )
462 );
463 }
464
465 return true;
466 }
467
468 /**
469 * Gets sync updates for a specific client from a room after a given cursor.
470 *
471 * Delegates cursor-based retrieval to the storage layer, then applies
472 * client-specific filtering and compaction logic.
473 *
474 * @since 7.0.0
475 *
476 * @param string $room Room identifier.
477 * @param int $client_id Client identifier.
478 * @param int $cursor Return updates after this cursor.
479 * @param bool $is_compactor True if this client is nominated to perform compaction.
480 * @return array{
481 * end_cursor: int,
482 * should_compact: bool,
483 * room: string,
484 * total_updates: int,
485 * updates: array<int, array{data: string, type: string}>,
486 * } Response data for this room.
487 */
488 private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array {
489 $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
490 $total_updates = $this->storage->get_update_count( $room );
491
492 // Filter out this client's updates, except compaction updates.
493 $typed_updates = array();
494 foreach ( $updates_after_cursor as $update ) {
495 if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) {
496 continue;
497 }
498
499 $typed_updates[] = array(
500 'data' => $update['data'],
501 'type' => $update['type'],
502 );
503 }
504
505 $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;
506
507 return array(
508 'end_cursor' => $this->storage->get_cursor( $room ),
509 'room' => $room,
510 'should_compact' => $should_compact,
511 'total_updates' => $total_updates,
512 'updates' => $typed_updates,
513 );
514 }
515 }
516 }
517