storage = $storage;
}
/**
* Registers REST API routes.
*
* @since 7.0.0
*/
public function register_routes(): void {
$typed_update_args = array(
'properties' => array(
'data' => array(
'type' => 'string',
'required' => true,
'maxLength' => self::MAX_UPDATE_DATA_SIZE,
),
'type' => array(
'type' => 'string',
'required' => true,
'enum' => array(
self::UPDATE_TYPE_COMPACTION,
self::UPDATE_TYPE_SYNC_STEP1,
self::UPDATE_TYPE_SYNC_STEP2,
self::UPDATE_TYPE_UPDATE,
),
),
),
'required' => true,
'type' => 'object',
);
$room_args = array(
'after' => array(
'minimum' => 0,
'required' => true,
'type' => 'integer',
),
'awareness' => array(
'required' => true,
'type' => array( 'object', 'null' ),
),
'client_id' => array(
'minimum' => 1,
'required' => true,
'type' => 'integer',
),
'room' => array(
'required' => true,
'type' => 'string',
'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$',
),
'updates' => array(
'items' => $typed_update_args,
'minItems' => 0,
'required' => true,
'type' => 'array',
),
);
register_rest_route(
self::REST_NAMESPACE,
'/updates',
array(
'methods' => array( WP_REST_Server::CREATABLE ),
'callback' => array( $this, 'handle_request' ),
'permission_callback' => array( $this, 'check_permissions' ),
'validate_callback' => array( $this, 'validate_request' ),
'args' => array(
'rooms' => array(
'items' => array(
'properties' => $room_args,
'type' => 'object',
),
'maxItems' => self::MAX_ROOMS_PER_REQUEST,
'required' => true,
'type' => 'array',
),
),
)
);
}
/**
* Checks if the current user has permission to access a room.
*
* @since 7.0.0
*
* @param WP_REST_Request $request The REST request.
* @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
*/
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(
'rest_cannot_edit',
__( 'You do not have permission to perform this action' ),
array( 'status' => rest_authorization_required_code() )
);
}
$rooms = $request['rooms'];
$wp_user_id = get_current_user_id();
foreach ( $rooms as $room ) {
$client_id = $room['client_id'];
$room = $room['room'];
// Check that the client_id is not already owned by another user.
$existing_awareness = $this->storage->get_awareness_state( $room );
foreach ( $existing_awareness as $entry ) {
if ( $client_id === $entry['client_id'] && $wp_user_id !== $entry['wp_user_id'] ) {
return new WP_Error(
'rest_cannot_edit',
__( 'Client ID is already in use by another user.' ),
array( 'status' => rest_authorization_required_code() )
);
}
}
$type_parts = explode( '/', $room, 2 );
$object_parts = explode( ':', $type_parts[1] ?? '', 2 );
$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(
'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.' ),
$room
),
array( 'status' => rest_authorization_required_code() )
);
}
}
return true;
}
/**
* Validates that the request body does not exceed the maximum allowed size.
*
* Runs as the route-level validate_callback, after per-arg schema
* validation has already passed.
*
* @since 7.0.0
*
* @param WP_REST_Request $request The REST request.
* @return true|WP_Error True if valid, WP_Error if the body is too large.
*/
public function validate_request( WP_REST_Request $request ) {
$body = $request->get_body();
if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) {
return new WP_Error(
'rest_sync_body_too_large',
__( 'Request body is too large.' ),
array( 'status' => 413 )
);
}
return true;
}
/**
* Handles request: stores sync updates and awareness data, and returns
* updates the client is missing.
*
* @since 7.0.0
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function handle_request( WP_REST_Request $request ) {
$rooms = $request['rooms'];
$response = array(
'rooms' => array(),
);
foreach ( $rooms as $room_request ) {
$awareness = $room_request['awareness'];
$client_id = $room_request['client_id'];
$cursor = $room_request['after'];
$room = $room_request['room'];
// Merge awareness state.
$merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );
// The lowest client ID is nominated to perform compaction when needed.
$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 ) {
$result = $this->process_sync_update( $room, $client_id, $cursor, $update );
if ( is_wp_error( $result ) ) {
return $result;
}
}
// Get updates for this client.
$room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor );
$room_response['awareness'] = $merged_awareness;
$response['rooms'][] = $room_response;
}
return new WP_REST_Response( $response, 200 );
}
/**
* Checks if the current user can sync a specific entity type.
*
* @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 numeric 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 {
if ( is_string( $object_id ) ) {
if ( ! ctype_digit( $object_id ) ) {
return false;
}
$object_id = (int) $object_id;
}
if ( null !== $object_id && $object_id <= 0 ) {
// Object ID must be numeric if provided.
return false;
}
// Validate permissions for the provided object ID.
if ( is_int( $object_id ) ) {
// Handle single post type entities with a defined object ID.
if ( 'postType' === $entity_kind ) {
if ( get_post_type( $object_id ) !== $entity_name ) {
// Post is not of the specified post type.
return false;
}
return current_user_can( 'edit_post', $object_id );
}
// Handle single taxonomy term entities with a defined object ID.
if ( 'taxonomy' === $entity_kind ) {
$term_exists = term_exists( $object_id, $entity_name );
if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) {
// Either term doesn't exist OR term is not in specified taxonomy.
return false;
}
return current_user_can( 'edit_term', $object_id );
}
// Handle single comment entities with a defined object ID.
if ( 'root' === $entity_kind && 'comment' === $entity_name ) {
return current_user_can( 'edit_comment', $object_id );
}
}
// 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.
$allowed_collection_entity_kinds = array(
'postType',
'root',
'taxonomy',
);
return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
}
/**
* 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