diff --git a/wp-admin/includes/schema.php b/wp-admin/includes/schema.php index 795060f28b..1f772c929f 100644 --- a/wp-admin/includes/schema.php +++ b/wp-admin/includes/schema.php @@ -563,6 +563,9 @@ function populate_options( array $options = array() ) { // 6.9.0 'wp_notes_notify' => 1, + + // 7.0.0 + 'enable_real_time_collaboration' => 1, ); // 3.3.0 diff --git a/wp-admin/options-writing.php b/wp-admin/options-writing.php index 6f85b54679..1943f42176 100644 --- a/wp-admin/options-writing.php +++ b/wp-admin/options-writing.php @@ -109,6 +109,13 @@ unset( $post_formats['standard'] ); + + + + /> + + + diff --git a/wp-admin/options.php b/wp-admin/options.php index 8db5cf50f2..57c22be86d 100644 --- a/wp-admin/options.php +++ b/wp-admin/options.php @@ -153,6 +153,7 @@ $allowed_options = array( 'default_email_category', 'default_link_category', 'default_post_format', + 'enable_real_time_collaboration', ), ); $allowed_options['misc'] = array(); diff --git a/wp-includes/certificates/ca-bundle.crt b/wp-includes/certificates/ca-bundle.crt index 65be891eea..5d325ac6fa 100644 --- a/wp-includes/certificates/ca-bundle.crt +++ b/wp-includes/certificates/ca-bundle.crt @@ -1,7 +1,7 @@ ## ## Bundle of CA Root Certificates ## -## Certificate data from Mozilla as of: Tue Dec 2 04:12:02 2025 GMT +## Certificate data from Mozilla as of: Tue Nov 4 04:12:02 2025 GMT ## ## Find updated versions here: https://curl.se/docs/caextract.html ## @@ -15,8 +15,8 @@ ## an Apache+mod_ssl webserver for SSL client authentication. ## Just configure this file as the SSLCACertificateFile. ## -## Conversion done with mk-ca-bundle.pl version 1.30. -## SHA256: a903b3cd05231e39332515ef7ebe37e697262f39515a52015c23c62805b73cd0 +## Conversion done with mk-ca-bundle.pl version 1.29. +## SHA256: 039132bff5179ce57cec5803ba59fe37abe6d0297aeb538c5af27847f0702517 ## @@ -3167,6 +3167,96 @@ bbd+NvBNEU/zy4k6LHiRUKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xk dUfFVZDj/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA== -----END CERTIFICATE----- +CommScope Public Trust ECC Root-01 +================================== +-----BEGIN CERTIFICATE----- +MIICHTCCAaOgAwIBAgIUQ3CCd89NXTTxyq4yLzf39H91oJ4wCgYIKoZIzj0EAwMwTjELMAkGA1UE +BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz +dCBFQ0MgUm9vdC0wMTAeFw0yMTA0MjgxNzM1NDNaFw00NjA0MjgxNzM1NDJaME4xCzAJBgNVBAYT +AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg +RUNDIFJvb3QtMDEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARLNumuV16ocNfQj3Rid8NeeqrltqLx +eP0CflfdkXmcbLlSiFS8LwS+uM32ENEp7LXQoMPwiXAZu1FlxUOcw5tjnSCDPgYLpkJEhRGnSjot +6dZoL0hOUysHP029uax3OVejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBSOB2LAUN3GGQYARnQE9/OufXVNMDAKBggqhkjOPQQDAwNoADBlAjEAnDPfQeMjqEI2 +Jpc1XHvr20v4qotzVRVcrHgpD7oh2MSg2NED3W3ROT3Ek2DS43KyAjB8xX6I01D1HiXo+k515liW +pDVfG2XqYZpwI7UNo5uSUm9poIyNStDuiw7LR47QjRE= +-----END CERTIFICATE----- + +CommScope Public Trust ECC Root-02 +================================== +-----BEGIN CERTIFICATE----- +MIICHDCCAaOgAwIBAgIUKP2ZYEFHpgE6yhR7H+/5aAiDXX0wCgYIKoZIzj0EAwMwTjELMAkGA1UE +BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz +dCBFQ0MgUm9vdC0wMjAeFw0yMTA0MjgxNzQ0NTRaFw00NjA0MjgxNzQ0NTNaME4xCzAJBgNVBAYT +AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg +RUNDIFJvb3QtMDIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR4MIHoYx7l63FRD/cHB8o5mXxO1Q/M +MDALj2aTPs+9xYa9+bG3tD60B8jzljHz7aRP+KNOjSkVWLjVb3/ubCK1sK9IRQq9qEmUv4RDsNuE +SgMjGWdqb8FuvAY5N9GIIvejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBTmGHX/72DehKT1RsfeSlXjMjZ59TAKBggqhkjOPQQDAwNnADBkAjAmc0l6tqvmSfR9 +Uj/UQQSugEODZXW5hYA4O9Zv5JOGq4/nich/m35rChJVYaoR4HkCMHfoMXGsPHED1oQmHhS48zs7 +3u1Z/GtMMH9ZzkXpc2AVmkzw5l4lIhVtwodZ0LKOag== +-----END CERTIFICATE----- + +CommScope Public Trust RSA Root-01 +================================== +-----BEGIN CERTIFICATE----- +MIIFbDCCA1SgAwIBAgIUPgNJgXUWdDGOTKvVxZAplsU5EN0wDQYJKoZIhvcNAQELBQAwTjELMAkG +A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU +cnVzdCBSU0EgUm9vdC0wMTAeFw0yMTA0MjgxNjQ1NTRaFw00NjA0MjgxNjQ1NTNaME4xCzAJBgNV +BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1 +c3QgUlNBIFJvb3QtMDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwSGWjDR1C45Ft +nYSkYZYSwu3D2iM0GXb26v1VWvZVAVMP8syMl0+5UMuzAURWlv2bKOx7dAvnQmtVzslhsuitQDy6 +uUEKBU8bJoWPQ7VAtYXR1HHcg0Hz9kXHgKKEUJdGzqAMxGBWBB0HW0alDrJLpA6lfO741GIDuZNq +ihS4cPgugkY4Iw50x2tBt9Apo52AsH53k2NC+zSDO3OjWiE260f6GBfZumbCk6SP/F2krfxQapWs +vCQz0b2If4b19bJzKo98rwjyGpg/qYFlP8GMicWWMJoKz/TUyDTtnS+8jTiGU+6Xn6myY5QXjQ/c +Zip8UlF1y5mO6D1cv547KI2DAg+pn3LiLCuz3GaXAEDQpFSOm117RTYm1nJD68/A6g3czhLmfTif +BSeolz7pUcZsBSjBAg/pGG3svZwG1KdJ9FQFa2ww8esD1eo9anbCyxooSU1/ZOD6K9pzg4H/kQO9 +lLvkuI6cMmPNn7togbGEW682v3fuHX/3SZtS7NJ3Wn2RnU3COS3kuoL4b/JOHg9O5j9ZpSPcPYeo +KFgo0fEbNttPxP/hjFtyjMcmAyejOQoBqsCyMWCDIqFPEgkBEa801M/XrmLTBQe0MXXgDW1XT2mH ++VepuhX2yFJtocucH+X8eKg1mp9BFM6ltM6UCBwJrVbl2rZJmkrqYxhTnCwuwwIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUN12mmnQywsL5x6YVEFm4 +5P3luG0wDQYJKoZIhvcNAQELBQADggIBAK+nz97/4L1CjU3lIpbfaOp9TSp90K09FlxD533Ahuh6 +NWPxzIHIxgvoLlI1pKZJkGNRrDSsBTtXAOnTYtPZKdVUvhwQkZyybf5Z/Xn36lbQnmhUQo8mUuJM +3y+Xpi/SB5io82BdS5pYV4jvguX6r2yBS5KPQJqTRlnLX3gWsWc+QgvfKNmwrZggvkN80V4aCRck +jXtdlemrwWCrWxhkgPut4AZ9HcpZuPN4KWfGVh2vtrV0KnahP/t1MJ+UXjulYPPLXAziDslg+Mkf +Foom3ecnf+slpoq9uC02EJqxWE2aaE9gVOX2RhOOiKy8IUISrcZKiX2bwdgt6ZYD9KJ0DLwAHb/W +NyVntHKLr4W96ioDj8z7PEQkguIBpQtZtjSNMgsSDesnwv1B10A8ckYpwIzqug/xBpMu95yo9GA+ +o/E4Xo4TwbM6l4c/ksp4qRyv0LAbJh6+cOx69TOY6lz/KwsETkPdY34Op054A5U+1C0wlREQKC6/ +oAI+/15Z0wUOlV9TRe9rh9VIzRamloPh37MG88EU26fsHItdkJANclHnYfkUyq+Dj7+vsQpZXdxc +1+SWrVtgHdqul7I52Qb1dgAT+GhMIbA1xNxVssnBQVocicCMb3SgazNNtQEo/a2tiRc7ppqEvOuM +6sRxJKi6KfkIsidWNTJf6jn7MZrVGczw +-----END CERTIFICATE----- + +CommScope Public Trust RSA Root-02 +================================== +-----BEGIN CERTIFICATE----- +MIIFbDCCA1SgAwIBAgIUVBa/O345lXGN0aoApYYNK496BU4wDQYJKoZIhvcNAQELBQAwTjELMAkG +A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU +cnVzdCBSU0EgUm9vdC0wMjAeFw0yMTA0MjgxNzE2NDNaFw00NjA0MjgxNzE2NDJaME4xCzAJBgNV +BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1 +c3QgUlNBIFJvb3QtMDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDh+g77aAASyE3V +rCLENQE7xVTlWXZjpX/rwcRqmL0yjReA61260WI9JSMZNRTpf4mnG2I81lDnNJUDMrG0kyI9p+Kx +7eZ7Ti6Hmw0zdQreqjXnfuU2mKKuJZ6VszKWpCtYHu8//mI0SFHRtI1CrWDaSWqVcN3SAOLMV2MC +e5bdSZdbkk6V0/nLKR8YSvgBKtJjCW4k6YnS5cciTNxzhkcAqg2Ijq6FfUrpuzNPDlJwnZXjfG2W +Wy09X6GDRl224yW4fKcZgBzqZUPckXk2LHR88mcGyYnJ27/aaL8j7dxrrSiDeS/sOKUNNwFnJ5rp +M9kzXzehxfCrPfp4sOcsn/Y+n2Dg70jpkEUeBVF4GiwSLFworA2iI540jwXmojPOEXcT1A6kHkIf +hs1w/tkuFT0du7jyU1fbzMZ0KZwYszZ1OC4PVKH4kh+Jlk+71O6d6Ts2QrUKOyrUZHk2EOH5kQMr +eyBUzQ0ZGshBMjTRsJnhkB4BQDa1t/qp5Xd1pCKBXbCL5CcSD1SIxtuFdOa3wNemKfrb3vOTlycE +VS8KbzfFPROvCgCpLIscgSjX74Yxqa7ybrjKaixUR9gqiC6vwQcQeKwRoi9C8DfF8rhW3Q5iLc4t +Vn5V8qdE9isy9COoR+jUKgF4z2rDN6ieZdIs5fq6M8EGRPbmz6UNp2YINIos8wIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUR9DnsSL/nSz12Vdgs7Gx +cJXvYXowDQYJKoZIhvcNAQELBQADggIBAIZpsU0v6Z9PIpNojuQhmaPORVMbc0RTAIFhzTHjCLqB +KCh6krm2qMhDnscTJk3C2OVVnJJdUNjCK9v+5qiXz1I6JMNlZFxHMaNlNRPDk7n3+VGXu6TwYofF +1gbTl4MgqX67tiHCpQ2EAOHyJxCDut0DgdXdaMNmEMjRdrSzbymeAPnCKfWxkxlSaRosTKCL4BWa +MS/TiJVZbuXEs1DIFAhKm4sTg7GkcrI7djNB3NyqpgdvHSQSn8h2vS/ZjvQs7rfSOBAkNlEv41xd +gSGn2rtO/+YHqP65DSdsu3BaVXoT6fEqSWnHX4dXTEN5bTpl6TBcQe7rd6VzEojov32u5cSoHw2O +HG1QAk8mGEPej1WFsQs3BWDJVTkSBKEqz3EWnzZRSb9wO55nnPt7eck5HHisd5FUmrh1CoFSl+Nm +YWvtPjgelmFV4ZFUjO2MJB+ByRCac5krFk5yAD9UG/iNuovnFNa2RU9g7Jauwy8CTl2dlklyALKr +dVwPaFsdZcJfMw8eD/A7hvWwTruc9+olBdytoptLFwG+Qt81IR2tq670v64fG9PiO/yzcnMcmyiQ +iRM9HcEARwmWmjgb3bHPDcK0RPOWlc4yOo80nOAXx17Org3bhzjlP1v9mxnhMUF6cKojawHhRUzN +lM47ni3niAIi9G7oyOzWPPO5std3eqx7 +-----END CERTIFICATE----- + Telekom Security TLS ECC Root 2020 ================================== -----BEGIN CERTIFICATE----- diff --git a/wp-includes/collaboration.php b/wp-includes/collaboration.php new file mode 100644 index 0000000000..1da7f2c367 --- /dev/null +++ b/wp-includes/collaboration.php @@ -0,0 +1,24 @@ +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( + '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' ), + '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 ) { + // 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']; + + foreach ( $rooms as $room ) { + $room = $room['room']; + $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; + } + + /** + * 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 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 single post type entities with a defined object ID. + if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + return current_user_can( 'edit_post', (int) $object_id ); + } + + // Handle single taxonomy term entities with a defined object ID. + if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { + $taxonomy = get_taxonomy( $entity_name ); + return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms ); + } + + // 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|null $awareness_update Awareness state sent by the client. + * @return array> 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 ); + $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 ) { + 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 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'] ] = $entry['state']; + } + + return $response; + } + + /** + * Processes a sync update based on its type. + * + * @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 ) { + $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 ) { + if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + return new WP_Error( + 'rest_sync_storage_error', + __( 'Failed to remove updates during compaction.' ), + array( 'status' => 500 ) + ); + } + + return $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. + */ + return $this->add_update( $room, $client_id, $type, $data ); + } + + return new WP_Error( + 'rest_invalid_update_type', + __( 'Invalid sync update type.' ), + 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 ) { + $update = array( + 'client_id' => $client_id, + 'data' => $data, + 'type' => $type, + ); + + if ( ! $this->storage->add_update( $room, $update ) ) { + return new WP_Error( + 'rest_sync_storage_error', + __( 'Failed to store sync update.' ), + array( 'status' => 500 ) + ); + } + + return true; + } + + /** + * 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. + * + * @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{ + * end_cursor: int, + * should_compact: bool, + * room: string, + * total_updates: int, + * updates: 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'], + ); + } + + $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD; + + return array( + 'end_cursor' => $this->storage->get_cursor( $room ), + 'room' => $room, + 'should_compact' => $should_compact, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, + ); + } +} diff --git a/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/wp-includes/collaboration/class-wp-sync-post-meta-storage.php new file mode 100644 index 0000000000..d0f1c99736 --- /dev/null +++ b/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -0,0 +1,322 @@ + + */ + private array $room_cursors = array(); + + /** + * Cache of update counts by room. + * + * @since 7.0.0 + * @var array + */ + private array $room_update_counts = array(); + + /** + * Cache of storage post IDs by room hash. + * + * @since 7.0.0 + * @var array + */ + private static array $storage_post_ids = array(); + + /** + * Adds a sync update to a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param mixed $update Sync update. + * @return bool True on success, false on failure. + */ + public function add_update( string $room, $update ): bool { + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return false; + } + + // Create an envelope and stamp each update to enable cursor-based filtering. + $envelope = array( + 'timestamp' => $this->get_time_marker(), + 'value' => $update, + ); + + return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); + } + + /** + * Retrieves all sync updates for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return array 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( $room ); + if ( null === $post_id ) { + return array(); + } + + $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false ); + + if ( ! is_array( $updates ) ) { + $updates = array(); + } + + // Filter out any updates that don't have the expected structure. + $updates = array_filter( + $updates, + static function ( $update ): bool { + return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); + } + ); + + $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( $room ); + if ( null === $post_id ) { + return array(); + } + + $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true ); + + if ( ! is_array( $awareness ) ) { + return array(); + } + + return array_values( $awareness ); + } + + /** + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. + */ + public function set_awareness_state( string $room, array $awareness ): bool { + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return false; + } + + // update_post_meta returns false if the value is the same as the existing value. + update_post_meta( $post_id, self::AWARENESS_META_KEY, $awareness ); + return true; + } + + /** + * 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 or creates the storage post for a given room. + * + * Each room gets its own dedicated post so that post meta cache + * invalidation is scoped to a single room rather than all of them. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int|null Post ID. + */ + private function get_storage_post_id( string $room ): ?int { + $room_hash = md5( $room ); + + if ( isset( self::$storage_post_ids[ $room_hash ] ) ) { + return self::$storage_post_ids[ $room_hash ]; + } + + // Try to find an existing post for this room. + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'publish', + 'name' => $room_hash, + 'fields' => 'ids', + ) + ); + + $post_id = array_first( $posts ); + if ( is_int( $post_id ) ) { + self::$storage_post_ids[ $room_hash ] = $post_id; + return $post_id; + } + + // Create new post for this room. + $post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => 'Sync Storage', + 'post_name' => $room_hash, + ) + ); + + if ( is_int( $post_id ) ) { + self::$storage_post_ids[ $room_hash ] = $post_id; + return $post_id; + } + + return null; + } + + /** + * Gets the current time in milliseconds as a comparable time marker. + * + * @since 7.0.0 + * + * @return int Current time in milliseconds. + */ + private function get_time_marker(): int { + return (int) 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 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, + fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] + ); + + return wp_list_pluck( $updates, 'value' ); + } + + /** + * 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. + * @return bool True on success, false on failure. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return false; + } + + $all_updates = $this->get_all_updates( $room ); + + // Remove all updates for the room and re-store only those that are newer than the cursor. + if ( ! delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ) ) { + return false; + } + + // Re-store envelopes directly to avoid double-wrapping by add_update(). + $add_result = true; + foreach ( $all_updates as $envelope ) { + if ( $add_result && $envelope['timestamp'] >= $cursor ) { + $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); + } + } + + return $add_result; + } +} diff --git a/wp-includes/collaboration/interface-wp-sync-storage.php b/wp-includes/collaboration/interface-wp-sync-storage.php new file mode 100644 index 0000000000..d84dbeb1e4 --- /dev/null +++ b/wp-includes/collaboration/interface-wp-sync-storage.php @@ -0,0 +1,86 @@ + Awareness state. + */ + public function get_awareness_state( string $room ): array; + + /** + * Gets 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 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. + * @return bool True on success, false on failure. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): bool; + + /** + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. + */ + public function set_awareness_state( string $room, array $awareness ): bool; +} diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index cc010a7b62..301b846343 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -786,6 +786,9 @@ add_action( 'deleted_post', '_wp_after_delete_font_family', 10, 2 ); add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); add_action( 'init', '_wp_register_default_font_collections' ); +// Collaboration. +add_action( 'admin_init', 'wp_collaboration_inject_setting' ); + // Add ignoredHookedBlocks metadata attribute to the template and template part post types. add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' ); add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' ); diff --git a/wp-includes/option.php b/wp-includes/option.php index 8a9a2c3c89..d39cf349f2 100644 --- a/wp-includes/option.php +++ b/wp-includes/option.php @@ -2897,6 +2897,18 @@ function register_initial_settings() { ) ); + register_setting( + 'writing', + 'enable_real_time_collaboration', + array( + 'type' => 'boolean', + 'description' => __( 'Enable Real-Time Collaboration' ), + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => true, + 'show_in_rest' => true, + ) + ); + register_setting( 'reading', 'posts_per_page', diff --git a/wp-includes/post.php b/wp-includes/post.php index 1b36ad58fc..eefdaafb0f 100644 --- a/wp-includes/post.php +++ b/wp-includes/post.php @@ -657,6 +657,41 @@ function create_initial_post_types() { ) ); + if ( get_option( 'enable_real_time_collaboration' ) ) { + register_post_type( + 'wp_sync_storage', + array( + 'labels' => array( + 'name' => __( 'Sync Updates' ), + 'singular_name' => __( 'Sync Update' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + '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' ), + ) + ); + } + register_post_status( 'publish', array( @@ -8611,6 +8646,7 @@ function use_block_editor_for_post_type( $post_type ) { * Registers any additional post meta fields. * * @since 6.3.0 Adds `wp_pattern_sync_status` meta field to the wp_block post type so an unsynced option can be added. + * @since 7.0.0 Adds `_crdt_document` meta field to post types so that CRDT documents can be persisted. * * @link https://github.com/WordPress/gutenberg/pull/51144 */ @@ -8630,4 +8666,30 @@ function wp_create_initial_post_meta() { ), ) ); + + if ( get_option( 'enable_real_time_collaboration' ) ) { + register_meta( + 'post', + '_crdt_document', + array( + 'auth_callback' => static 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', + ) + ); + } } diff --git a/wp-includes/rest-api.php b/wp-includes/rest-api.php index 981892025c..f144957286 100644 --- a/wp-includes/rest-api.php +++ b/wp-includes/rest-api.php @@ -428,6 +428,13 @@ function create_initial_rest_routes() { // Icons. $icons_controller = new WP_REST_Icons_Controller(); $icons_controller->register_routes(); + + // Collaboration. + if ( get_option( 'enable_real_time_collaboration' ) ) { + $sync_storage = new WP_Sync_Post_Meta_Storage(); + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_server->register_routes(); + } } /** diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 8cb0a4987f..b47a614c87 100644 --- a/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -232,7 +232,31 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller { $post_lock = wp_check_post_lock( $post->ID ); $is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status; - if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock ) { + /* + * In the context of real-time collaboration, all peers are effectively + * authors and we don't want to vary behavior based on whether they are the + * original author. Always target an autosave revision. + * + * This avoids the following issue when real-time collaboration is enabled: + * + * - Autosaves from the original author (if they have the post lock) will + * target the saved post. + * + * - Autosaves from other users are applied to a post revision. + * + * - If any user reloads a post, they load changes from the author's autosave. + * + * - The saved post has now diverged from the persisted CRDT document. The + * content (and/or title or excerpt) are now "ahead" of the persisted CRDT + * document. + * + * - When the persisted CRDT document is loaded, a diff is computed against + * the saved post. This diff is then applied to the in-memory CRDT + * document, which can lead to duplicate inserts or deletions. + */ + $is_collaboration_enabled = get_option( 'enable_real_time_collaboration' ); + + if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) { /* * Draft posts for the same author: autosaving updates the post and does not create a revision. * Convert the post object to an array and add slashes, wp_update_post() expects escaped array. diff --git a/wp-includes/version.php b/wp-includes/version.php index 0d5b096fdd..c7750488a3 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.0-alpha-61688'; +$wp_version = '7.0-alpha-61689'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. diff --git a/wp-settings.php b/wp-settings.php index b437ae153b..e90be87754 100644 --- a/wp-settings.php +++ b/wp-settings.php @@ -300,6 +300,10 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/abilities.php'; +require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; +require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';