Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a7b52ee
Add WP_Sync_Save_Server class for CRDT persistence
alecgeatches Jun 2, 2026
02ab971
Persist non-user-CRDT-saves through the /save endpoint
alecgeatches Jun 2, 2026
a8ea8d3
Add comment explaining why we still persist the CRDT doc through meta…
alecgeatches Jun 2, 2026
de2e1a1
Update tests for /save endpoint, add new PHPUnit tests
alecgeatches Jun 2, 2026
f075120
Use a per-room queue so we can't fire /save requests out of order to …
alecgeatches Jun 2, 2026
e2027ba
Moved shared room parsing logic to WP_Sync_Config
alecgeatches Jun 2, 2026
666d23c
Add tests for WP_Sync_Config parsing and permissions checks
alecgeatches Jun 2, 2026
9a9f4ea
Add client-side supportsPersistence config to avoid saving CRDT docs …
alecgeatches Jun 2, 2026
2f2642e
Distinguish sync manager updates by persistenceEvent 'userSave'/'back…
alecgeatches Jun 2, 2026
2016a25
Update tests with persistenceEvent
alecgeatches Jun 2, 2026
ab7162a
Add persistedAt to notify other users to refetch an updated CRDT doc …
alecgeatches Jun 2, 2026
2a2eaf6
Remove persistedAt/persistenceEvent. Don't require other users to ser…
alecgeatches Jun 2, 2026
e38b07a
Add additional RTC-related autosave tests to ensure CRDT docs are onl…
alecgeatches Jun 2, 2026
07ff0c1
Merge branch 'trunk' into add/rtc-doc-persistence-endpoint
alecgeatches Jun 4, 2026
84ba0c0
Ensure collaboration is enabled on all autosave tests that aren't exp…
alecgeatches Jun 4, 2026
3aa0658
Check update output before calling get_post_meta() for CRDT doc save
alecgeatches Jun 4, 2026
ee8bfeb
Remove double permissions check in sync save server
alecgeatches Jun 4, 2026
b2095bf
Use conventional promise handling in save-crdt-doc testing
alecgeatches Jun 4, 2026
7e78ee0
Rely on maxLength schema for document size limits
alecgeatches Jun 4, 2026
cb9c492
Avoid Promise.withResolvers() because it's not available on node v20 …
alecgeatches Jun 4, 2026
6751411
Return an empty payload on successful sync
alecgeatches Jun 8, 2026
81b6d46
Fix array syntax
alecgeatches Jun 8, 2026
e481c7e
Merge branch 'trunk' into add/rtc-doc-persistence-endpoint
alecgeatches Jun 8, 2026
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
92 changes: 6 additions & 86 deletions lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
* @package gutenberg
*/

if ( ! class_exists( 'WP_Sync_Config' ) ) {
require_once __DIR__ . '/class-wp-sync-config.php';
}

if ( ! class_exists( 'WP_HTTP_Polling_Sync_Server' ) ) {

/**
Expand Down Expand Up @@ -230,14 +234,8 @@ public function check_permissions( WP_REST_Request $request ) {
}
}

$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 ) ) {
$parsed_room = WP_Sync_Config::parse_room( $room );
if ( null === $parsed_room || ! WP_Sync_Config::can_user_sync_entity_type( $parsed_room['entity_kind'], $parsed_room['entity_name'], $parsed_room['object_id'] ) ) {
$forbidden_rooms[] = $room;
}
}
Expand Down Expand Up @@ -332,84 +330,6 @@ public function handle_request( WP_REST_Request $request ) {
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.
*
Expand Down
196 changes: 196 additions & 0 deletions lib/compat/wordpress-7.1/class-wp-sync-config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php
/**
* WP_Sync_Config class
*
* @package gutenberg
*/

if ( ! class_exists( 'WP_Sync_Config' ) ) {

/**
* Configuration helpers for sync entities.
*
* @since 7.1.0
* @access private
*/
class WP_Sync_Config {
/**
* Entity sync configuration.
*
* Used to convert sync entity rooms (e.g. `postType/post:100`) to
* entity types and reject sync or save requests for unsupported entity
* types.
*
* @since 7.1.0
* @var array
*/
const ENTITY_CONFIG = array(
'postType' => array(
'object_type' => 'post',
'supports_crdt_doc_persistence' => true,
),
);

/**
* Parses a sync room identifier into entity parts.
*
* @since 7.1.0
*
* @param string $room Room identifier.
* @return array{entity_kind:string, entity_name:string, object_id:string|null}|null Parsed room, or null if invalid.
*/
public static function parse_room( string $room ): ?array {
$type_parts = explode( '/', $room, 2 );
if ( 2 !== count( $type_parts ) || '' === $type_parts[0] || '' === $type_parts[1] ) {
return null;
}

if ( false !== strpos( $type_parts[1], '/' ) ) {
return null;
}

$object_parts = explode( ':', $type_parts[1], 2 );
if ( '' === $object_parts[0] ) {
return null;
}

$object_id = $object_parts[1] ?? null;
if ( '' === $object_id ) {
return null;
}

return array(
'entity_kind' => $type_parts[0],
'entity_name' => $object_parts[0],
'object_id' => $object_id,
);
}

/**
* Checks if the current user can sync a specific entity type.
*
* @since 7.1.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.
*/
public static 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 ) {
return false;
}

if ( is_int( $object_id ) ) {
if ( 'postType' === $entity_kind ) {
if ( get_post_type( $object_id ) !== $entity_name ) {
return false;
}
return current_user_can( 'edit_post', $object_id );
}

if ( 'taxonomy' === $entity_kind ) {
$term_exists = term_exists( $object_id, $entity_name );
if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) {
return false;
}

return current_user_can( 'edit_term', $object_id );
}

if ( 'root' === $entity_kind && 'comment' === $entity_name ) {
return current_user_can( 'edit_comment', $object_id );
}
}

if ( null !== $object_id ) {
return false;
}

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 );
}

$allowed_collection_entity_kinds = array(
'postType',
'root',
'taxonomy',
);

return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
}

/**
* Checks if a single entity room supports persisted CRDT documents.
*
* @since 7.1.0
*
* @param string $entity_kind The entity kind.
* @param string $entity_name The entity name.
* @param string|null $object_id The entity ID.
* @return bool True if the entity room supports persisted CRDT documents.
*/
public static function supports_crdt_doc_persistence( string $entity_kind, string $entity_name, ?string $object_id ): bool {
return null !== self::get_crdt_doc_persistence_post_id( $entity_kind, $entity_name, $object_id );
}

/**
* Gets the post ID used to persist a CRDT document for an entity room.
*
* @since 7.1.0
*
* @param string $entity_kind The entity kind.
* @param string $entity_name The entity name.
* @param string|null $object_id The entity ID.
* @return int|null Post ID if persistence is supported, otherwise null.
*/
public static function get_crdt_doc_persistence_post_id( string $entity_kind, string $entity_name, ?string $object_id ): ?int {
if ( ! self::entity_kind_supports_crdt_doc_persistence( $entity_kind ) || ! is_string( $object_id ) || ! ctype_digit( $object_id ) ) {
return null;
}

$post_id = (int) $object_id;
if ( $post_id <= 0 || 'post' !== self::get_object_type( $entity_kind ) || get_post_type( $post_id ) !== $entity_name ) {
return null;
}

return $post_id;
}

/**
* Checks if an entity kind supports persisted CRDT documents.
*
* @since 7.1.0
*
* @param string $entity_kind The entity kind.
* @return bool True if the entity kind supports persisted CRDT documents.
*/
private static function entity_kind_supports_crdt_doc_persistence( string $entity_kind ): bool {
return true === ( self::ENTITY_CONFIG[ $entity_kind ]['supports_crdt_doc_persistence'] ?? false );
}

/**
* Gets the object type used to persist CRDT documents for an entity kind.
*
* @since 7.1.0
*
* @param string $entity_kind The entity kind.
* @return string|null Object type, or null if not supported.
*/
private static function get_object_type( string $entity_kind ): ?string {
return self::ENTITY_CONFIG[ $entity_kind ]['object_type'] ?? null;
}
}
}
Loading
Loading