diff --git a/backport-changelog/7.0/10894.md b/backport-changelog/7.0/10894.md new file mode 100644 index 00000000000000..f55e198f0d95f3 --- /dev/null +++ b/backport-changelog/7.0/10894.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/10894 + +* https://github.com/WordPress/gutenberg/pull/75366 diff --git a/lib/experimental/sync/class-gutenberg-rest-autosaves-controller.php b/lib/compat/wordpress-7.0/class-gutenberg-rest-autosaves-controller.php similarity index 97% rename from lib/experimental/sync/class-gutenberg-rest-autosaves-controller.php rename to lib/compat/wordpress-7.0/class-gutenberg-rest-autosaves-controller.php index 92e224bd5179d3..9f49a627aff7e9 100644 --- a/lib/experimental/sync/class-gutenberg-rest-autosaves-controller.php +++ b/lib/compat/wordpress-7.0/class-gutenberg-rest-autosaves-controller.php @@ -101,7 +101,7 @@ public function create_item( $request ) { * Load the real-time collaboration setting and, when enabled, ensure that an * an autosave revision is always targeted. */ - $is_collaboration_enabled = get_option( 'gutenberg_enable_real_time_collaboration' ); + $is_collaboration_enabled = get_option( 'enable_real_time_collaboration' ); if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) { /* diff --git a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php new file mode 100644 index 00000000000000..83dc8d9e619f50 --- /dev/null +++ b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php @@ -0,0 +1,451 @@ +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, + ), + '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' => 'object', + ), + 'client_id' => array( + 'minimum' => 1, + 'required' => true, + 'type' => 'integer', + ), + 'room' => array( + 'sanitize_callback' => 'sanitize_text_field', + 'required' => true, + 'type' => 'string', + ), + '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' ), + 'args' => array( + 'rooms' => array( + 'items' => array( + 'properties' => $room_args, + 'type' => 'object', + ), + '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 ) { + $rooms = $request['rooms']; + + foreach ( $rooms as $room ) { + $room = $room['room']; + $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', + 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 ) + ); + } + } + + 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 = 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 ); + } + + // 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. + * + * @param string $entity_kind The entity kind. + * @param string $entity_name The entity name. + * @param string|null $object_id The object ID (if applicable). + * @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. + if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + return current_user_can( 'edit_post', absint( $object_id ) ); + } + + // All of the remaining checks are for collections. If an object ID is + // provided, reject the request. + if ( null !== $object_id ) { + return false; + } + + // 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. + * + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param array|null $awareness_update Awareness state sent by the client. + * @return array> Updated awareness state for the room. + */ + private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { + $existing_awareness = $this->storage->get_awareness_state( $room ); + $updated_awareness = array(); + $current_time = time(); + + foreach ( $existing_awareness as $entry ) { + // Remove this client's entry (it will be updated below). + if ( $client_id === $entry['client_id'] ) { + continue; + } + + // Remove entries that have expired. + if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) { + continue; + } + + $updated_awareness[] = $entry; + } + + // Add this client's awareness state. + if ( null !== $awareness_update ) { + $updated_awareness[] = array( + 'client_id' => $client_id, + 'state' => $awareness_update, + 'updated_at' => $current_time, + ); + } + + $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']; + } + + return $response; + } + + /** + * 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 $update Sync update with 'type' and 'data' fields. + */ + private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { + $data = $update['data']; + $type = $update['type']; + + switch ( $type ) { + case self::UPDATE_TYPE_COMPACTION: + /* + * Compaction replaces updates the client has already seen. Only remove + * updates with markers before the client's cursor to preserve updates + * that arrived since the client's last sync. + * + * Check for a newer compaction update first. If one exists, skip this + * compaction to avoid overwriting it. + */ + $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); + $has_newer_compaction = false; + + foreach ( $updates_after_cursor as $existing ) { + if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) { + $has_newer_compaction = true; + break; + } + } + + if ( ! $has_newer_compaction ) { + $this->storage->remove_updates_before_cursor( $room, $cursor ); + $this->add_update( $room, $client_id, $type, $data ); + } + break; + + case self::UPDATE_TYPE_SYNC_STEP1: + case self::UPDATE_TYPE_SYNC_STEP2: + case self::UPDATE_TYPE_UPDATE: + /* + * Sync step 1 announces a client's state vector. Other clients need + * to see it so they can respond with sync_step2 containing missing + * updates. The cursor-based filtering prevents re-delivery. + * + * Sync step 2 contains updates for a specific client. + * + * All updates are stored persistently. + */ + $this->add_update( $room, $client_id, $type, $data ); + break; + } + } + + /** + * Adds an update to a room's update list via storage. + * + * @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. + */ + private function add_update( string $room, int $client_id, string $type, string $data ): void { + $update = array( + 'client_id' => $client_id, + 'data' => $data, + 'type' => $type, + ); + + $this->storage->add_update( $room, $update ); + } + + /** + * Gets sync updates for a specific client from a room after a given cursor. + * + * Delegates cursor-based retrieval to the storage layer, then applies + * client-specific filtering and compaction logic. + * + * @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 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 ); + $total_updates = $this->storage->get_update_count( $room ); + + // Filter out this client's updates, except compaction updates. + $typed_updates = array(); + foreach ( $updates_after_cursor as $update ) { + if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) { + continue; + } + + $typed_updates[] = array( + 'data' => $update['data'], + 'type' => $update['type'], + ); + } + + // Determine if this client should perform compaction. + $compaction_request = null; + if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { + $compaction_request = $updates_after_cursor; + } + + return array( + 'compaction_request' => $compaction_request, + 'end_cursor' => $this->storage->get_cursor( $room ), + 'room' => $room, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, + ); + } + } +} diff --git a/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php b/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php new file mode 100644 index 00000000000000..1a6596656ea7e7 --- /dev/null +++ b/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php @@ -0,0 +1,302 @@ + + */ + private $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @var array + */ + private $room_update_counts = array(); + + /** + * Singleton post ID for storing sync data. + * + * @var int|null + */ + private static ?int $storage_post_id = null; + + /** + * Initializer. + * + * @since 7.0.0 + */ + public function init(): void {} + + /** + * Adds a sync update to a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param mixed $update Sync update. + */ + public function add_update( string $room, mixed $update ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + // Create an envelope and stamp each update to enable cursor-based filtering. + $envelope = array( + 'timestamp' => $this->get_time_marker(), + 'value' => $update, + ); + + add_post_meta( $post_id, $meta_key, $envelope, false ); + } + + /** + * Retrieve all sync updates for a given room. + * + * @param string $room Room identifier. + * @return array Array of sync updates. + */ + private function get_all_updates( string $room ): array { + $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. + + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + $updates = get_post_meta( $post_id, $meta_key, false ); + + if ( ! is_array( $updates ) ) { + $updates = array(); + } + + $this->room_update_counts[ $room ] = count( $updates ); + + return $updates; + } + + /** + * Gets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return array Awareness state. + */ + public function get_awareness_state( string $room ): array { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_awareness_meta_key( $room ); + $awareness = get_post_meta( $post_id, $meta_key, true ); + + if ( ! is_array( $awareness ) ) { + return array(); + } + + return $awareness; + } + + /** + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + */ + public function set_awareness_state( string $room, array $awareness ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_awareness_meta_key( $room ); + + update_post_meta( $post_id, $meta_key, $awareness ); + } + + /** + * Gets the meta key for a room's awareness state. + * + * @param string $room Room identifier. + * @return string Meta key. + */ + private function get_awareness_meta_key( string $room ): string { + return 'wp_sync_awareness_' . md5( $room ); + } + + /** + * Gets the current cursor for a given room. + * + * The cursor is set during get_updates_after_cursor() and represents the + * point in time just before the updates were retrieved, with a small buffer + * to ensure consistency. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int { + return $this->room_cursors[ $room ] ?? 0; + } + + /** + * Gets the meta key for a room's updates. + * + * @param string $room Room identifier. + * @return string Meta key. + */ + private function get_room_meta_key( string $room ): string { + return 'wp_sync_update_' . md5( $room ); + } + + /** + * Gets or creates the singleton post for storing sync data. + * + * @return int|null Post ID. + */ + private function get_storage_post_id(): ?int { + if ( is_int( self::$storage_post_id ) ) { + return self::$storage_post_id; + } + + // Try to find existing post. + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'publish', + 'fields' => 'ids', + 'order' => 'ASC', + ) + ); + + // array_first not introduced until WP 6.9 + $post_id = $posts[0] ?? null; + if ( is_int( $post_id ) ) { + self::$storage_post_id = $post_id; + return self::$storage_post_id; + } + + // Create new post if none exists. + $post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => 'Sync Storage', + ) + ); + + if ( is_int( $post_id ) ) { + self::$storage_post_id = $post_id; + } + + return self::$storage_post_id; + } + + /** + * Gets the current time in milliseconds as a comparable time marker. + * + * @return int Current time in milliseconds. + */ + private function get_time_marker(): int { + return floor( microtime( true ) * 1000 ); + } + + /** + * Gets the number of updates stored for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Number of updates stored for the room. + */ + public function get_update_count( string $room ): int { + return $this->room_update_counts[ $room ] ?? 0; + } + + /** + * Retrieves sync updates from a room for a given client and cursor. Updates + * from the specified client should be excluded. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Array of sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array { + $all_updates = $this->get_all_updates( $room ); + $updates = array(); + + foreach ( $all_updates as $update ) { + if ( $update['timestamp'] > $cursor ) { + $updates[] = $update; + } + } + + // Sort by timestamp to ensure order. + usort( + $updates, + function ( $a, $b ) { + return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); + } + ); + + return wp_list_pluck( $updates, 'value' ); + } + + /** + * Removes all sync updates for a given room. + * + * @param string $room Room identifier. + */ + private function remove_all_updates( string $room ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + delete_post_meta( $post_id, $meta_key ); + } + + /** + * Removes updates from a room that are older than the given cursor. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): void { + $all_updates = $this->get_all_updates( $room ); + $this->remove_all_updates( $room ); + + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + // Re-store envelopes directly to avoid double-wrapping by add_update(). + foreach ( $all_updates as $envelope ) { + if ( $envelope['timestamp'] >= $cursor ) { + add_post_meta( $post_id, $meta_key, $envelope, false ); + } + } + } + } +} diff --git a/lib/compat/wordpress-7.0/collaboration.php b/lib/compat/wordpress-7.0/collaboration.php new file mode 100644 index 00000000000000..aff7585da70bd6 --- /dev/null +++ b/lib/compat/wordpress-7.0/collaboration.php @@ -0,0 +1,151 @@ + array( + 'name' => __( 'Sync Updates', 'gutenberg' ), + 'singular_name' => __( 'Sync Update', 'gutenberg' ), + ), + 'public' => false, + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'do_not_allow', + 'read_private_posts' => 'do_not_allow', + 'create_posts' => 'do_not_allow', + 'publish_posts' => 'do_not_allow', + 'edit_posts' => 'do_not_allow', + 'edit_others_posts' => 'do_not_allow', + 'edit_published_posts' => 'do_not_allow', + 'delete_posts' => 'do_not_allow', + 'delete_others_posts' => 'do_not_allow', + 'delete_published_posts' => 'do_not_allow', + ), + 'map_meta_cap' => false, + 'publicly_queryable' => false, + 'query_var' => false, + 'rewrite' => false, + 'show_in_menu' => false, + 'show_in_rest' => false, + 'show_ui' => false, + 'supports' => array( 'custom-fields' ), + ) + ); + } + add_action( 'init', 'gutenberg_register_sync_storage_post_type' ); +} + +if ( ! function_exists( 'gutenberg_register_collaboration_rest_routes' ) ) { + /** + * Registers REST API routes for collaborative editing. + */ + function gutenberg_register_collaboration_rest_routes(): void { + $sync_storage = new WP_Sync_Post_Meta_Storage(); + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_server->register_routes(); + } + add_action( 'rest_api_init', 'gutenberg_register_collaboration_rest_routes' ); +} + +if ( ! function_exists( 'wp_collaboration_register_meta' ) ) { + /** + * Registers post meta for persisting CRDT documents. + */ + function gutenberg_rest_api_crdt_post_meta() { + // This string must match WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/sync. + $persisted_crdt_post_meta_key = '_crdt_document'; + + register_meta( + 'post', + $persisted_crdt_post_meta_key, + array( + 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { + return user_can( $user_id, 'edit_post', $object_id ); + }, + /* + * Revisions must be disabled because we always want to preserve + * the latest persisted CRDT document, even when a revision is restored. + * This ensures that we can continue to apply updates to a shared document + * and peers can simply merge the restored revision like any other incoming + * update. + * + * If we want to persist CRDT documents alongside revisions in the + * future, we should do so in a separate meta key. + */ + 'revisions_enabled' => false, + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); + } + add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' ); +} + +if ( ! function_exists( 'wp_collaboration_inject_setting' ) ) { + /** + * Registers the real-time collaboration setting. + */ + function gutenberg_register_real_time_collaboration_setting() { + $option_name = 'enable_real_time_collaboration'; + + register_setting( + 'writing', + $option_name, + array( + 'type' => 'boolean', + 'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ), + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => false, + 'show_in_rest' => true, + ) + ); + + add_settings_field( + $option_name, + __( 'Collaboration', 'gutenberg' ), + function () use ( $option_name ) { + $option_value = get_option( $option_name ); + + ?> + + Awareness state. + */ + public function get_awareness_state( string $room ): array; + + /** + * Get the current cursor for a given room. This should return a monotonically + * increasing integer that represents the last update that was returned for the + * room during the current request. This allows clients to retrieve updates + * after a specific cursor on subsequent requests. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int; + + /** + * Gets the total number of stored updates for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Total number of updates. + */ + public function get_update_count( string $room ): int; + + /** + * Retrieves sync updates from a room for a given client and cursor. Updates + * from the specified client should be excluded. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Array of sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array; + + /** + * Removes updates from a room that are older than the provided cursor. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): void; + + /** + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + */ + public function set_awareness_state( string $room, array $awareness ): void; + } +} diff --git a/lib/compat/wordpress-7.0/rest-api.php b/lib/compat/wordpress-7.0/rest-api.php index ab54aef4b7d875..e35acdb12c6321 100644 --- a/lib/compat/wordpress-7.0/rest-api.php +++ b/lib/compat/wordpress-7.0/rest-api.php @@ -108,3 +108,24 @@ function gutenberg_rest_theme_global_styles_link_rel_7_0( $response, $theme ) { return $response; } add_filter( 'rest_prepare_theme', 'gutenberg_rest_theme_global_styles_link_rel_7_0', 10, 2 ); + +/** + * Overrides the default REST controller for autosaves to fix real-time + * collaboration on draft posts. + * + * When RTC is enabled, draft autosaves from all users update the post directly + * instead of creating per-user autosave revisions depending on post lock and + * assigned author. + * + * Only overrides when autosave_rest_controller_class is not explicitly set, + * i.e. when WP_REST_Autosaves_Controller would be used by default. Post types + * with their own specialized autosave controller (e.g. templates) are left alone. + */ +function gutenberg_override_autosaves_rest_controller( $args ) { + if ( empty( $args['autosave_rest_controller_class'] ) ) { + $args['autosave_rest_controller_class'] = 'Gutenberg_REST_Autosaves_Controller'; + } + return $args; +} + +add_filter( 'register_post_type_args', 'gutenberg_override_autosaves_rest_controller', 10, 1 ); diff --git a/lib/experimental/sync/class-gutenberg-http-polling-sync-server.php b/lib/experimental/sync/class-gutenberg-http-polling-sync-server.php deleted file mode 100644 index 389b56b597b815..00000000000000 --- a/lib/experimental/sync/class-gutenberg-http-polling-sync-server.php +++ /dev/null @@ -1,464 +0,0 @@ -storage = $storage; - } - - /** - * Initialize the sync server. - */ - public function init(): void { - add_action( 'rest_api_init', array( $this, 'register_routes' ) ); - } - - /** - * Register REST API routes. - */ - public function register_routes(): void { - $typed_update_args = array( - 'properties' => array( - 'data' => array( - 'type' => 'string', - 'required' => true, - ), - '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' => 'object', - ), - 'client_id' => array( - 'minimum' => 1, - 'required' => true, - 'type' => 'integer', - ), - 'room' => array( - 'sanitize_callback' => 'sanitize_text_field', - 'required' => true, - 'type' => 'string', - ), - 'updates' => array( - 'items' => $typed_update_args, - 'minItems' => 0, - 'required' => true, - 'type' => 'array', - ), - ); - - // POST /wp/v2/sync/updates - 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' ), - 'args' => array_merge( - array( - 'rooms' => array( - 'items' => array( - 'properties' => $room_args, - 'type' => 'object', - ), - 'required' => true, - 'type' => 'array', - ), - ) - ), - ) - ); - } - - /** - * Check if the current user has permission to access a room. - * - * @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 ) { - $rooms = $request->get_param( 'rooms' ); - - foreach ( $rooms as $room ) { - // Parse sync object type (format: kind/name) - $room = $room['room']; - $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', - array( 'status' => 400 ) - ); - } - - // Extract Gutenberg entity kind, entity name and object ID. - $entity_kind = $type_parts[0]; - $entity_name = $object_parts[0]; - $object_id = null; - - if ( isset( $object_parts[1] ) ) { - $object_id = $object_parts[1]; - } - - if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { - return new WP_Error( - 'forbidden', - sprintf( 'You do not have permission to sync this entity: %s.', $room ), - array( 'status' => 401 ) - ); - } - } - - return true; - } - - /** - * Check 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 int|null $object_id The object ID (if applicable). - * @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. - if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { - return current_user_can( 'edit_post', absint( $object_id ) ); - } - - // All of the remaining checks for for collections. If an object ID is - // provided, reject the request. - if ( null !== $object_id ) { - return false; - } - - // 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 ); - } - - /** - * Handle request: store sync updates and awareness data, and return updates - * the client is missing. - * - * @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->get_param( '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 = 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 ); - } - - // Get updates for this client. - $room_response = $this->get_updates_after( $room, $client_id, $cursor, $is_compactor ); - $room_response['awareness'] = $merged_awareness; - - $response['rooms'][] = $room_response; - } - - return new WP_REST_Response( $response, 200 ); - } - - /** - * Process and store an awareness update from a client. - * - * @param string $room Room identifier. - * @param int $client_id Client identifier. - * @param array|null $awareness_update Awareness state sent by the client. - * @return array Updated awareness state for the room. - */ - private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { - // Get existing awareness state and filter out expired clients. - $existing_awareness = $this->storage->get_awareness_state( $room ); - $updated_awareness = array(); - $current_time = time(); - - foreach ( $existing_awareness as $entry ) { - // Remove this client's entry (it will be updated below). - if ( $client_id === $entry['client_id'] ) { - continue; - } - - // Remove entries that have expired. - if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) { - continue; - } - - $updated_awareness[] = $entry; - } - - // Add this client's awareness state. - if ( null !== $awareness_update ) { - $updated_awareness[] = array( - 'client_id' => $client_id, - 'state' => $awareness_update, - 'updated_at' => $current_time, - ); - } - - // Save updated awareness state. - $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']; - } - - return $response; - } - - /** - * Process 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 $update Sync update with 'type' and 'data' fields. - */ - private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { - $data = $update['data']; - $type = $update['type']; - - switch ( $type ) { - case self::UPDATE_TYPE_COMPACTION: - // Compaction replaces updates the client has already seen. Only remove - // updates with markers before the client's cursor to preserve updates - // that arrived since the client's last sync. - // - // The `remove_updates_before_compaction` method returns false if there - // is a newer compaction update already stored. - if ( $this->remove_updates_before_cursor( $room, $cursor ) ) { - $this->add_update( $room, $client_id, $type, $data ); - } - break; - - case self::UPDATE_TYPE_SYNC_STEP1: - // Sync step 1 announces a client's state vector. Other clients need - // to see it so they can respond with sync_step2 containing missing - // updates. The cursor-based filtering prevents re-delivery. - $this->add_update( $room, $client_id, $type, $data ); - break; - - case self::UPDATE_TYPE_SYNC_STEP2: - // Sync step 2 contains updates for a specific client. - // Store it but mark for single delivery (will be cleaned up after delivery). - $this->add_update( $room, $client_id, $type, $data ); - break; - - case self::UPDATE_TYPE_UPDATE: - // Regular document updates are stored persistently. - $this->add_update( $room, $client_id, $type, $data ); - break; - } - } - - /** - * Add an update to a room's update list. - * - * @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. - */ - private function add_update( string $room, int $client_id, string $type, string $data ): void { - $update_envelope = array( - 'client_id' => $client_id, - 'type' => $type, - 'data' => $data, - 'timestamp' => $this->get_time_marker(), - ); - - // Store the update - $this->storage->add_update( $room, $update_envelope ); - } - - /** - * Get the current time in milliseconds as a comparable time marker. - * - * @return int Current time in milliseconds. - */ - private function get_time_marker(): int { - return floor( microtime( true ) * 1000 ); - } - - /** - * Get sync updates from a room after a given cursor. - * - * @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 - */ - private function get_updates_after( string $room, int $client_id, int $cursor, bool $is_compactor ): array { - $end_cursor = $this->get_time_marker() - 100; // Small buffer to ensure consistency - $all_updates = $this->storage->get_all_updates( $room ); - $total_updates = count( $all_updates ); - $updates = array(); - - foreach ( $all_updates as $update ) { - // Skip updates from this client, unless they are compaction updates. - if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) { - continue; - } - - // Skip updates before our cursor. - if ( $update['timestamp'] > $cursor ) { - $updates[] = $update; - } - } - - // Sort by update timestamp to ensure order - usort( - $updates, - function ( $a, $b ) { - return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); - } - ); - - // Convert to typed update format for response. - $typed_updates = array(); - foreach ( $updates as $update ) { - $typed_updates[] = array( - 'data' => $update['data'], - 'type' => $update['type'], - ); - } - - // Determine if this client should perform compaction. - $compaction_request = null; - if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { - $compaction_request = $all_updates; - } - - return array( - 'compaction_request' => $compaction_request, - 'end_cursor' => $end_cursor, - 'room' => $room, - 'total_updates' => $total_updates, - 'updates' => $typed_updates, - ); - } - - /** - * Remove updates from a room that are older than the given compaction marker. - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True if this compaction is the latest, false if a newer compaction update exists. - */ - private function remove_updates_before_cursor( string $room, int $cursor ): bool { - $all_updates = $this->storage->get_all_updates( $room ); - $this->storage->remove_all_updates( $room ); - - $is_latest_compaction = true; - $updates_to_keep = array(); - - foreach ( $all_updates as $update ) { - if ( $update['timestamp'] >= $cursor ) { - $updates_to_keep[] = $update; - - if ( self::UPDATE_TYPE_COMPACTION === $update['type'] ) { - // If there is already a newer compaction update, return false. - $is_latest_compaction = false; - } - } - } - - // Replace all updates with filtered list. - foreach ( $updates_to_keep as $update ) { - $this->storage->add_update( $room, $update ); - } - - return $is_latest_compaction; - } -} diff --git a/lib/experimental/sync/class-gutenberg-sync-post-meta-storage.php b/lib/experimental/sync/class-gutenberg-sync-post-meta-storage.php deleted file mode 100644 index 4ecd6d03fa541b..00000000000000 --- a/lib/experimental/sync/class-gutenberg-sync-post-meta-storage.php +++ /dev/null @@ -1,183 +0,0 @@ - 'Gutenberg Sync Storage', - 'public' => false, - 'publicly_queryable' => false, - 'show_in_menu' => false, - 'show_in_rest' => false, - 'show_ui' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } - - /** - * Add a sync update to a given room. - * - * @param string $room Room identifier. - * @param array $update Sync update. - */ - public function add_update( string $room, array $update ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - - add_post_meta( $post_id, $meta_key, $update, false ); - } - - /** - * Retrieve sync updates for a given room. - * - * @param string $room Room identifier. - * @return array Array of sync updates. - */ - public function get_all_updates( string $room ): array { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - $updates = get_post_meta( $post_id, $meta_key, false ); - - if ( ! is_array( $updates ) ) { - $updates = array(); - } - - return $updates; - } - - /** - * Get the meta key for a room's awareness state. - * - * @param string $room Room identifier. - * @return string Meta key. - */ - private function get_awareness_meta_key( string $room ): string { - return 'sync_awareness_' . md5( $room ); - } - - /** - * Get awareness state for a given room. - * - * @param string $room Room identifier. - * @return array Merged awarenessstate. - */ - public function get_awareness_state( string $room ): array { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_awareness_meta_key( $room ); - $awareness = get_post_meta( $post_id, $meta_key, true ); - - if ( ! is_array( $awareness ) ) { - return array(); - } - - return $awareness; - } - - /** - * Get the meta key for a room's updates. - * - * @param string $room Room identifier. - * @return string Meta key. - */ - private function get_room_meta_key( string $room ): string { - return 'sync_update_' . md5( $room ); - } - - /** - * Get or create the singleton post for storing sync data. - * - * @return int Post ID. - */ - private function get_storage_post_id(): int { - if ( is_int( self::$storage_post_id ) ) { - return self::$storage_post_id; - } - - // Try to find existing post - $posts = get_posts( - array( - 'post_type' => self::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'orderby' => 'ID', - 'order' => 'ASC', - ) - ); - - if ( ! empty( $posts ) ) { - self::$storage_post_id = $posts[0]->ID; - return self::$storage_post_id; - } - - // Create new post if none exists - $post_id = wp_insert_post( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => 'publish', - 'post_title' => 'Gutenberg Sync Storage', - ) - ); - - if ( ! is_wp_error( $post_id ) ) { - self::$storage_post_id = $post_id; - } - - return self::$storage_post_id; - } - - /** - * Remove all sync updates for a given room. - * - * @param string $room Room identifier. - */ - public function remove_all_updates( string $room ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - - delete_post_meta( $post_id, $meta_key ); - } - - /** - * Set awareness state for a given room. - * - * @param string $room Room identifier. - * @param array $awareness Merged awareness state. - */ - public function set_awareness_state( string $room, array $awareness ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_awareness_meta_key( $room ); - - update_post_meta( $post_id, $meta_key, $awareness ); - } -} diff --git a/lib/experimental/sync/interface-gutenberg-sync-storage.php b/lib/experimental/sync/interface-gutenberg-sync-storage.php deleted file mode 100644 index f01187ad089d19..00000000000000 --- a/lib/experimental/sync/interface-gutenberg-sync-storage.php +++ /dev/null @@ -1,52 +0,0 @@ -init(); - - $sse_sync_server = new Gutenberg_HTTP_Polling_Sync_Server( $sync_storage ); - $sse_sync_server->init(); -} -add_action( 'init', 'gutenberg_rest_api_register_routes_for_collaborative_editing' ); - -/** - * Registers post meta for persisting CRDT documents. - */ -function gutenberg_rest_api_crdt_post_meta() { - // This string must match WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/sync. - $persisted_crdt_post_meta_key = '_crdt_document'; - - register_meta( - 'post', - $persisted_crdt_post_meta_key, - array( - 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { - return user_can( $user_id, 'edit_post', $object_id ); - }, - // IMPORTANT: Revisions must be disabled because we always want to preserve - // the latest persisted CRDT document, even when a revision is restored. - // This ensures that we can continue to apply updates to a shared document - // and peers can simply merge the restored revision like any other incoming - // update. - // - // If we want to persist CRDT documents alongisde revisions in the - // future, we should do so in a separate meta key. - 'revisions_enabled' => false, - 'show_in_rest' => true, - 'single' => true, - 'type' => 'string', - ) - ); -} -add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' ); - -/** - * Registers the real-time collaboration setting. - */ -function gutenberg_register_real_time_collaboration_setting() { - $option_name = 'gutenberg_enable_real_time_collaboration'; - - register_setting( - 'writing', - $option_name, - array( - 'type' => 'boolean', - 'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ), - 'sanitize_callback' => 'rest_sanitize_boolean', - 'default' => false, - 'show_in_rest' => true, - ) - ); - - add_settings_field( - $option_name, - __( 'Collaboration', 'gutenberg' ), - function () use ( $option_name ) { - $option_value = get_option( $option_name ); - - ?> - - { }, method: 'POST', parse: false, - path: '/wp/v2/sync/updates', + path: '/wp-sync/v1/updates', } ); } ); diff --git a/packages/sync/src/providers/http-polling/utils.ts b/packages/sync/src/providers/http-polling/utils.ts index aa1503e30faaad..07e37f36d627d4 100644 --- a/packages/sync/src/providers/http-polling/utils.ts +++ b/packages/sync/src/providers/http-polling/utils.ts @@ -14,7 +14,7 @@ import { type UpdateQueue, } from './types'; -const SYNC_API_PATH = '/wp/v2/sync/updates'; +const SYNC_API_PATH = '/wp-sync/v1/updates'; export function uint8ArrayToBase64( data: Uint8Array ): string { let binary = ''; diff --git a/packages/sync/src/providers/index.ts b/packages/sync/src/providers/index.ts index c32ab58374f8dd..2a8f48861503ae 100644 --- a/packages/sync/src/providers/index.ts +++ b/packages/sync/src/providers/index.ts @@ -42,7 +42,7 @@ export function getProviderCreators(): ProviderCreator[] { } // Check if real-time collaboration is enabled via WordPress setting. - if ( ! window.__wpSyncEnabled ) { + if ( ! window._wpCollaborationEnabled ) { return []; } diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 287fd7e9d112f9..d995a4ed964e5a 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -17,7 +17,7 @@ import type { WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from './config'; /* globalThis */ declare global { interface Window { - __wpSyncEnabled?: string; + _wpCollaborationEnabled?: string; } }