Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backport-changelog/7.0/10894.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ https://github.com/WordPress/wordpress-develop/pull/10894
* https://github.com/WordPress/gutenberg/pull/75681
* https://github.com/WordPress/gutenberg/pull/75682
* https://github.com/WordPress/gutenberg/pull/75708
* https://github.com/WordPress/gutenberg/pull/75711
* https://github.com/WordPress/gutenberg/pull/75746
133 changes: 89 additions & 44 deletions lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class WP_HTTP_Polling_Sync_Server {
* @since 7.0.0
* @var int
*/
const AWARENESS_TIMEOUT_IN_S = 30;
const AWARENESS_TIMEOUT = 30;

/**
* Threshold used to signal clients to send a compaction update.
Expand Down Expand Up @@ -75,9 +75,8 @@ class WP_HTTP_Polling_Sync_Server {
* Storage backend for sync updates.
*
* @since 7.0.0
* @var WP_Sync_Storage
*/
private $storage;
private WP_Sync_Storage $storage;

/**
* Constructor.
Expand Down Expand Up @@ -133,9 +132,9 @@ public function register_routes(): void {
'type' => 'integer',
),
'room' => array(
'sanitize_callback' => 'sanitize_text_field',
'required' => true,
'type' => 'string',
'required' => true,
'type' => 'string',
'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$',
),
'updates' => array(
'items' => $typed_update_args,
Expand Down Expand Up @@ -178,9 +177,9 @@ public function check_permissions( WP_REST_Request $request ) {
// Minimum cap check. Is user logged in with a contributor role or higher?
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'forbidden',
'rest_cannot_edit',
__( 'You do not have permission to perform this action', 'gutenberg' ),
array( 'status' => 401 )
array( 'status' => rest_authorization_required_code() )
);
}

Expand All @@ -191,27 +190,19 @@ public function check_permissions( WP_REST_Request $request ) {
$type_parts = explode( '/', $room, 2 );
$object_parts = explode( ':', $type_parts[1] ?? '', 2 );

if ( 2 !== count( $type_parts ) ) {
return new WP_Error(
'invalid_room_format',
__( 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id', 'gutenberg' ),
array( 'status' => 400 )
);
}

$entity_kind = $type_parts[0];
$entity_name = $object_parts[0];
$object_id = $object_parts[1] ?? null;

if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) {
return new WP_Error(
'forbidden',
'rest_cannot_edit',
sprintf(
/* translators: %s: The room name encodes the current entity being synced. */
__( 'You do not have permission to sync this entity: %s.', 'gutenberg' ),
$room
),
array( 'status' => 401 )
array( 'status' => rest_authorization_required_code() )
);
}
}
Expand Down Expand Up @@ -244,11 +235,17 @@ public function handle_request( WP_REST_Request $request ) {
$merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );

// The lowest client ID is nominated to perform compaction when needed.
$is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
$is_compactor = false;
if ( count( $merged_awareness ) > 0 ) {
$is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
}

// Process each update according to its type.
foreach ( $room_request['updates'] as $update ) {
$this->process_sync_update( $room, $client_id, $cursor, $update );
$result = $this->process_sync_update( $room, $client_id, $cursor, $update );
if ( is_wp_error( $result ) ) {
return $result;
}
}

// Get updates for this client.
Expand All @@ -264,15 +261,17 @@ public function handle_request( WP_REST_Request $request ) {
/**
* Checks if the current user can sync a specific entity type.
*
* @param string $entity_kind The entity kind.
* @param string $entity_name The entity name.
* @param string|null $object_id The object ID (if applicable).
* @since 7.0.0
*
* @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'.
* @param string $entity_name The entity name, e.g. 'post', 'category', 'site'.
* @param string|null $object_id The object ID / entity key for single entities, null for collections.
* @return bool True if user has permission, otherwise false.
*/
private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
// Handle post type entities.
// Handle single post type entities with a defined object ID.
if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) {
return current_user_can( 'edit_post', absint( $object_id ) );
return current_user_can( 'edit_post', (int) $object_id );
}

// Handle single taxonomy term entities with a defined object ID.
Expand All @@ -281,17 +280,27 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_
return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms );
}

// Handle root comment entities
// Handle single comment entities with a defined object ID.
if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) {
return current_user_can( 'edit_comment', (int) $object_id );
}

// All of the remaining checks are for collections. If an object ID is
// provided, reject the request.
// All the remaining checks are for collections. If an object ID is provided,
// reject the request.
if ( null !== $object_id ) {
return false;
}

// For postType collections, check if the user can edit posts of this type.
if ( 'postType' === $entity_kind ) {
$post_type_object = get_post_type_object( $entity_name );
if ( ! isset( $post_type_object->cap->edit_posts ) ) {
return false;
}

return current_user_can( $post_type_object->cap->edit_posts );
}

// Collection syncing does not exchange entity data. It only signals if
// another user has updated an entity in the collection. Therefore, we only
// compare against an allow list of collection types.
Expand All @@ -307,10 +316,12 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_
/**
* Processes and stores an awareness update from a client.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param array<string, mixed>|null $awareness_update Awareness state sent by the client.
* @return array<int, array<string, mixed>> Updated awareness state for the room.
* @return array<int, array<string, mixed>> Map of client ID to awareness state.
*/
private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
$existing_awareness = $this->storage->get_awareness_state( $room );
Expand All @@ -324,7 +335,7 @@ private function process_awareness_update( string $room, int $client_id, ?array
}

// Remove entries that have expired.
if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) {
if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) {
continue;
}

Expand All @@ -340,12 +351,13 @@ private function process_awareness_update( string $room, int $client_id, ?array
);
}

// This action can fail, but it shouldn't fail the entire request.
$this->storage->set_awareness_state( $room, $updated_awareness );

// Convert to client_id => state map for response.
$response = array();
foreach ( $updated_awareness as $entry ) {
$response[ $entry['client_id'] ] = (object) $entry['state'];
$response[ $entry['client_id'] ] = $entry['state'];
}

return $response;
Expand All @@ -354,12 +366,15 @@ private function process_awareness_update( string $room, int $client_id, ?array
/**
* Processes a sync update based on its type.
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param int $cursor Client cursor (marker of last seen update).
* @param array<string, mixed> $update Sync update with 'type' and 'data' fields.
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param int $cursor Client cursor (marker of last seen update).
* @param array{data: string, type: string} $update Sync update.
* @return true|WP_Error True on success, WP_Error on storage failure.
*/
private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void {
private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) {
$data = $update['data'];
$type = $update['type'];

Expand All @@ -384,8 +399,15 @@ private function process_sync_update( string $room, int $client_id, int $cursor,
}

if ( ! $has_newer_compaction ) {
$this->storage->remove_updates_before_cursor( $room, $cursor );
$this->add_update( $room, $client_id, $type, $data );
if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
return new WP_Error(
'rest_sync_storage_error',
__( 'Failed to remove updates during compaction.', 'gutenberg' ),
array( 'status' => 500 )
);
}

return $this->add_update( $room, $client_id, $type, $data );
}
break;

Expand All @@ -401,27 +423,43 @@ private function process_sync_update( string $room, int $client_id, int $cursor,
*
* All updates are stored persistently.
*/
$this->add_update( $room, $client_id, $type, $data );
break;
return $this->add_update( $room, $client_id, $type, $data );
}

return new WP_Error(
'rest_invalid_update_type',
__( 'Invalid sync update type.', 'gutenberg' ),
array( 'status' => 400 )
);
}

/**
* Adds an update to a room's update list via storage.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param string $type Update type (sync_step1, sync_step2, update, compaction).
* @param string $data Base64-encoded update data.
* @return true|WP_Error True on success, WP_Error on storage failure.
*/
private function add_update( string $room, int $client_id, string $type, string $data ): void {
private function add_update( string $room, int $client_id, string $type, string $data ) {
$update = array(
'client_id' => $client_id,
'data' => $data,
'type' => $type,
);

$this->storage->add_update( $room, $update );
if ( ! $this->storage->add_update( $room, $update ) ) {
return new WP_Error(
'rest_sync_storage_error',
__( 'Failed to store sync update.', 'gutenberg' ),
array( 'status' => 500 )
);
}

return true;
}

/**
Expand All @@ -430,11 +468,19 @@ private function add_update( string $room, int $client_id, string $type, string
* Delegates cursor-based retrieval to the storage layer, then applies
* client-specific filtering and compaction logic.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $client_id Client identifier.
* @param int $cursor Return updates after this cursor.
* @param bool $is_compactor True if this client is nominated to perform compaction.
* @return array<string, mixed> Response data for this room.
* @return array{
* end_cursor: int,
* should_compact: bool,
* room: string,
* total_updates: int,
* updates: array<int, array{data: string, type: string}>,
* } Response data for this room.
*/
private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array {
$updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
Expand All @@ -453,7 +499,6 @@ private function get_updates( string $room, int $client_id, int $cursor, bool $i
);
}

// Determine if this client should perform compaction.
$should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;

return array(
Expand Down
Loading
Loading