| 1 |
<?php |
| 2 |
/** |
| 3 |
* REST API: Gutenberg_REST_Autosaves_Controller class |
| 4 |
* |
| 5 |
* @package gutenberg |
| 6 |
*/ |
| 7 |
|
| 8 |
/** |
| 9 |
* Controller which provides REST endpoint for autosaves. |
| 10 |
* This overrides the core WP_REST_Autosaves_Controller to add support for |
| 11 |
* real-time collaboration fixes on draft posts. |
| 12 |
* |
| 13 |
* @see WP_REST_Autosaves_Controller |
| 14 |
*/ |
| 15 |
class Gutenberg_REST_Autosaves_Controller extends WP_REST_Autosaves_Controller { |
| 16 |
|
| 17 |
/** |
| 18 |
* Meta key holding the CRDT snapshot describing an autosave's content. |
| 19 |
* |
| 20 |
* @var string |
| 21 |
*/ |
| 22 |
public const CRDT_SNAPSHOT_META_KEY = '_crdt_autosave_snapshot'; |
| 23 |
|
| 24 |
/** |
| 25 |
* Request parameter holding the CRDT snapshot describing an autosave's |
| 26 |
* content. |
| 27 |
* |
| 28 |
* This string must match CRDT_AUTOSAVE_SNAPSHOT_KEY in @wordpress/core-data. |
| 29 |
* |
| 30 |
* @var string |
| 31 |
*/ |
| 32 |
public const CRDT_SNAPSHOT_PARAM = 'crdt_snapshot'; |
| 33 |
|
| 34 |
/** |
| 35 |
* Maximum accepted length of a stored CRDT snapshot, in bytes. |
| 36 |
* |
| 37 |
* A snapshot is a state vector plus a delete set, so it grows with the |
| 38 |
* much slower than content. Unlikely to exceed 1 MB, but use as a safety |
| 39 |
* backstop. |
| 40 |
* |
| 41 |
* @var int |
| 42 |
*/ |
| 43 |
private const MAX_CRDT_SNAPSHOT_LENGTH = MB_IN_BYTES; |
| 44 |
|
| 45 |
/** |
| 46 |
* Parent post controller. |
| 47 |
* |
| 48 |
* @since 5.0.0 |
| 49 |
* @var WP_REST_Controller |
| 50 |
*/ |
| 51 |
private $gutenberg_parent_controller; |
| 52 |
|
| 53 |
/** |
| 54 |
* Constructor. |
| 55 |
* |
| 56 |
* @since 5.0.0 |
| 57 |
* |
| 58 |
* @param string $parent_post_type Post type of the parent. |
| 59 |
*/ |
| 60 |
public function __construct( $parent_post_type ) { |
| 61 |
parent::__construct( $parent_post_type ); |
| 62 |
|
| 63 |
// Create an instance of the parent post type controller that is accessible |
| 64 |
// by this extended class. |
| 65 |
$post_type_object = get_post_type_object( $parent_post_type ); |
| 66 |
$parent_controller = $post_type_object->get_rest_controller(); |
| 67 |
|
| 68 |
if ( ! $parent_controller ) { |
| 69 |
$parent_controller = new WP_REST_Posts_Controller( $parent_post_type ); |
| 70 |
} |
| 71 |
|
| 72 |
$this->gutenberg_parent_controller = $parent_controller; |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Creates, updates or deletes an autosave revision. |
| 77 |
* |
| 78 |
* @since 5.0.0 |
| 79 |
* |
| 80 |
* @param WP_REST_Request $request Full details about the request. |
| 81 |
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. |
| 82 |
*/ |
| 83 |
public function create_item( $request ) { |
| 84 |
// Gutenberg selects this controller only when RTC is enabled. Preserve |
| 85 |
// Core behavior if it is registered explicitly or instantiated directly. |
| 86 |
if ( ! wp_is_collaboration_enabled() ) { |
| 87 |
return parent::create_item( $request ); |
| 88 |
} |
| 89 |
|
| 90 |
$post = $this->get_parent( $request['id'] ); |
| 91 |
|
| 92 |
if ( is_wp_error( $post ) ) { |
| 93 |
return $post; |
| 94 |
} |
| 95 |
|
| 96 |
// Autosave creation may fire this callback for revisioned post meta. |
| 97 |
if ( ! function_exists( 'wp_autosave_post_revisioned_meta_fields' ) ) { |
| 98 |
require_once ABSPATH . 'wp-admin/includes/post.php'; |
| 99 |
} |
| 100 |
|
| 101 |
// Post-type collaboration support is determined after the autosaves |
| 102 |
// controller is selected, so disabled post types must delegate to Core. |
| 103 |
if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) { |
| 104 |
return parent::create_item( $request ); |
| 105 |
} |
| 106 |
|
| 107 |
if ( ! defined( 'WP_RUN_CORE_TESTS' ) && ! defined( 'DOING_AUTOSAVE' ) ) { |
| 108 |
define( 'DOING_AUTOSAVE', true ); |
| 109 |
} |
| 110 |
|
| 111 |
$prepared_post = $this->gutenberg_parent_controller->prepare_item_for_database( $request ); |
| 112 |
$prepared_post->ID = $post->ID; |
| 113 |
$post_data = (array) $prepared_post; |
| 114 |
$meta = (array) $request->get_param( 'meta' ); |
| 115 |
|
| 116 |
/* |
| 117 |
* Regular draft autosaves must not update the parent post directly under |
| 118 |
* RTC. All peers share a persisted editing state in the CRDT, so their |
| 119 |
* autosaved changes must be stored in revisions. Applying those edits to |
| 120 |
* the parent post would make them appear to be external changes when the |
| 121 |
* editor next reloads, causing the same changes to be reapplied to the |
| 122 |
* CRDT and duplicated. |
| 123 |
* |
| 124 |
* The first peer to store an edit must still promote an auto-draft into |
| 125 |
* a real draft. Otherwise, peers could continue editing while the post |
| 126 |
* remains an unlisted auto-draft and may be lost. |
| 127 |
*/ |
| 128 |
$should_promote_auto_draft = ( |
| 129 |
'auto-draft' === $post->post_status && |
| 130 |
current_user_can( 'edit_post', $post->ID ) |
| 131 |
); |
| 132 |
|
| 133 |
if ( $should_promote_auto_draft ) { |
| 134 |
$autosave_id = wp_update_post( wp_slash( $post_data ), true ); |
| 135 |
} elseif ( $this->is_redundant_autosave( $post, $post_data, $meta ) ) { |
| 136 |
/* |
| 137 |
* Nothing changed from the latest shared state, so storing a |
| 138 |
* revision would only create an identical one. Avoid a no-op |
| 139 |
* revision because WordPress decides whether to warn about "a more |
| 140 |
* recent autosave" by comparing timestamps. |
| 141 |
*/ |
| 142 |
$autosave_id = $post->ID; |
| 143 |
} else { |
| 144 |
$autosave_id = $this->create_post_autosave( $post_data, $meta ); |
| 145 |
} |
| 146 |
|
| 147 |
if ( is_wp_error( $autosave_id ) ) { |
| 148 |
return $autosave_id; |
| 149 |
} |
| 150 |
|
| 151 |
// Only save a CRDT snapshot with an autosave (not with parent post) |
| 152 |
if ( $autosave_id !== $post->ID ) { |
| 153 |
$this->store_crdt_snapshot( $autosave_id, $request ); |
| 154 |
} |
| 155 |
|
| 156 |
$autosave = get_post( $autosave_id ); |
| 157 |
$request->set_param( 'context', 'edit' ); |
| 158 |
|
| 159 |
$response = $this->prepare_item_for_response( $autosave, $request ); |
| 160 |
$response = rest_ensure_response( $response ); |
| 161 |
|
| 162 |
return $response; |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* Stores the CRDT snapshot describing the content an autosave captured. |
| 167 |
* |
| 168 |
* The snapshot allows an editor session verify that a shared document |
| 169 |
* already contains everything the autosave holds, so the "more recent |
| 170 |
* autosave" notice can be suppressed as redundant. |
| 171 |
* |
| 172 |
* Anything invalid is dropped rather than erroring, and any previously |
| 173 |
* stored snapshot is cleared, because a missing snapshot only means the |
| 174 |
* editor falls back to showing the "newer autosave" notice. |
| 175 |
* |
| 176 |
* @param int $autosave_id Autosave revision ID. |
| 177 |
* @param WP_REST_Request $request Full details about the request. |
| 178 |
* @return void |
| 179 |
*/ |
| 180 |
private function store_crdt_snapshot( $autosave_id, $request ) { |
| 181 |
$snapshot = $request->get_param( self::CRDT_SNAPSHOT_PARAM ); |
| 182 |
|
| 183 |
$is_valid_snapshot = ( |
| 184 |
is_string( $snapshot ) && |
| 185 |
'' !== $snapshot && |
| 186 |
strlen( $snapshot ) <= self::MAX_CRDT_SNAPSHOT_LENGTH |
| 187 |
); |
| 188 |
|
| 189 |
if ( ! $is_valid_snapshot ) { |
| 190 |
/* |
| 191 |
* The autosave revision is reused across autosaves, so a snapshot |
| 192 |
* stored by an earlier request would otherwise remain attached to |
| 193 |
* this request's newer content and could wrongly vouch for it. |
| 194 |
* Clear it so the editor falls back to showing the notice. |
| 195 |
*/ |
| 196 |
delete_metadata( 'post', $autosave_id, self::CRDT_SNAPSHOT_META_KEY ); |
| 197 |
return; |
| 198 |
} |
| 199 |
|
| 200 |
update_metadata( 'post', $autosave_id, self::CRDT_SNAPSHOT_META_KEY, wp_slash( $snapshot ) ); |
| 201 |
} |
| 202 |
|
| 203 |
/** |
| 204 |
* Determines whether an incoming autosave would be a redundant no-op. |
| 205 |
* |
| 206 |
* Core's WP_REST_Autosaves_Controller::create_post_autosave() avoids a |
| 207 |
* redundant write by comparing the incoming autosave against the parent post. |
| 208 |
* That baseline is wrong under RTC. The parent draft is intentionally never |
| 209 |
* updated, so peer autosaves can accumulate as revisions while the parent stays |
| 210 |
* stale. An autosave that matches the latest shared revision still looks different |
| 211 |
* from the parent, so core stores another revision identical to the previous |
| 212 |
* one, which renders as a blank diff on the revisions page. |
| 213 |
* |
| 214 |
* The correct baseline is the most recent revision when it is newer |
| 215 |
* than the parent (the RTC case), falling back to the parent when no revision |
| 216 |
* exists yet. The revisioned post fields (title, content, excerpt) and |
| 217 |
* revisioned meta (e.g. `footnotes`) are compared, matching the fields core |
| 218 |
* itself diffs. Non-revisioned meta (e.g. `_crdt_document`) is excluded. |
| 219 |
* |
| 220 |
* @since 7.2.0 |
| 221 |
* |
| 222 |
* @param WP_Post $post The saved parent post. |
| 223 |
* @param array $post_data Prepared autosave post data. |
| 224 |
* @param array $meta Meta values submitted with the autosave. |
| 225 |
* @return bool Whether the autosave can be skipped without losing anything. |
| 226 |
*/ |
| 227 |
private function is_redundant_autosave( $post, $post_data, $meta ) { |
| 228 |
$baseline = $this->get_autosave_comparison_baseline( $post ); |
| 229 |
|
| 230 |
$new_autosave = _wp_post_revision_data( $post_data, true ); |
| 231 |
$revision_fields = _wp_post_revision_fields( $baseline ); |
| 232 |
|
| 233 |
foreach ( array_intersect( array_keys( $new_autosave ), array_keys( $revision_fields ) ) as $field ) { |
| 234 |
if ( normalize_whitespace( $new_autosave[ $field ] ) !== normalize_whitespace( $baseline->$field ) ) { |
| 235 |
return false; |
| 236 |
} |
| 237 |
} |
| 238 |
|
| 239 |
foreach ( wp_post_revision_meta_keys( $post->post_type ) as $meta_key ) { |
| 240 |
// get_metadata_raw avoids the registered default. Treat an unset value |
| 241 |
// and an empty string as equivalent so the editor's empty default for |
| 242 |
// an unset key is not mistaken for a change. |
| 243 |
$old_meta = get_metadata_raw( 'post', $baseline->ID, $meta_key, true ) ?? ''; |
| 244 |
$new_meta = $meta[ $meta_key ] ?? ''; |
| 245 |
|
| 246 |
if ( $old_meta !== $new_meta ) { |
| 247 |
return false; |
| 248 |
} |
| 249 |
} |
| 250 |
|
| 251 |
return true; |
| 252 |
} |
| 253 |
|
| 254 |
/** |
| 255 |
* Returns the post to compare an incoming autosave against. |
| 256 |
* |
| 257 |
* Under RTC the parent draft is not updated with an autosave, so its latest content |
| 258 |
* lives in the most recent revision. When that revision is newer than the parent it is |
| 259 |
* the correct baseline; otherwise (no revisions yet, or the parent was updated |
| 260 |
* directly) the parent post is used. |
| 261 |
* |
| 262 |
* @since 7.2.0 |
| 263 |
* |
| 264 |
* @param WP_Post $post The saved parent post. |
| 265 |
* @return WP_Post The post or revision to compare against. |
| 266 |
*/ |
| 267 |
private function get_autosave_comparison_baseline( $post ) { |
| 268 |
// wp_get_post_revisions() returns revisions newest-first by default, and |
| 269 |
// includes per-user autosaves (they are revisions), so the first entry is |
| 270 |
// the most recent shared content. |
| 271 |
$revisions = wp_get_post_revisions( $post->ID, array( 'posts_per_page' => 1 ) ); |
| 272 |
$latest_revision = empty( $revisions ) ? null : array_shift( $revisions ); |
| 273 |
|
| 274 |
// Note that a draft which has never been updated keeps the floating |
| 275 |
// post_modified_gmt of '0000-00-00 00:00:00', so any revision compares |
| 276 |
// as newer than it. That is the desired outcome: under RTC the |
| 277 |
// revisions hold newer content than the untouched parent draft. |
| 278 |
if ( |
| 279 |
$latest_revision && |
| 280 |
$latest_revision->post_modified_gmt >= $post->post_modified_gmt |
| 281 |
) { |
| 282 |
return $latest_revision; |
| 283 |
} |
| 284 |
|
| 285 |
return $post; |
| 286 |
} |
| 287 |
} |
| 288 |
|