diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index 0ab54a3a0d384..580a9bb4d2a3f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -569,6 +569,28 @@ protected function get_post( $id ) { return $post; } + /** + * Adds a `Last-Modified` response header based on the post modified time (GMT). (#47676) + * + * Enables clients to send `If-Unmodified-Since` on subsequent write requests. + * + * @since 7.1.0 + * + * @param WP_REST_Response $response Response object. + * @param WP_Post $post Post object. + */ + protected function add_last_modified_header( $response, $post ) { + $modified = get_post_datetime( $post, 'modified', 'gmt' ); + if ( ! $modified ) { + return; + } + + $response->header( + 'Last-Modified', + gmdate( 'D, d M Y H:i:s', $modified->getTimestamp() ) . ' GMT' + ); + } + /** * Checks if a given request has access to read a post. * @@ -673,6 +695,8 @@ public function get_item( $request ) { $response->link_header( 'alternate', get_permalink( $post->ID ), array( 'type' => 'text/html' ) ); } + $this->add_last_modified_header( $response, $post ); + return $response; } @@ -930,6 +954,21 @@ public function update_item_permissions_check( $request ) { ); } + $if_unmodified_since = $request->get_header( 'If-Unmodified-Since' ); + if ( $if_unmodified_since ) { + $client_time = strtotime( $if_unmodified_since ); + if ( false !== $client_time ) { + $modified = get_post_datetime( $post, 'modified', 'gmt' ); + if ( $modified && $modified->getTimestamp() > $client_time ) { + return new WP_Error( + 'rest_precondition_failed', + __( 'Sorry, the post has been modified on the server since you started editing it. Conflict resolution is required.' ), + array( 'status' => 412 ) + ); + } + } + } + return true; } @@ -1040,8 +1079,9 @@ public function update_item( $request ) { // Filter is fired in WP_REST_Attachments_Controller subclass. if ( 'attachment' === $this->post_type ) { - $response = $this->prepare_item_for_response( $post, $request ); - return rest_ensure_response( $response ); + $response = rest_ensure_response( $this->prepare_item_for_response( $post, $request ) ); + $this->add_last_modified_header( $response, $post ); + return $response; } /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ @@ -1049,9 +1089,11 @@ public function update_item( $request ) { wp_after_insert_post( $post, true, $post_before ); - $response = $this->prepare_item_for_response( $post, $request ); + $response = rest_ensure_response( $this->prepare_item_for_response( $post, $request ) ); - return rest_ensure_response( $response ); + $this->add_last_modified_header( $response, $post ); + + return $response; } /** diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index 212ddde70dd83..c33a1480b04f0 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -4549,6 +4549,61 @@ public function test_update_item_with_same_template_that_no_longer_exists() { $this->assertSame( 'post-my-invalid-template.php', $data['template'] ); } + /** + * Tests If-Unmodified-Since conditional updates and Last-Modified headers. + * + * @covers WP_REST_Posts_Controller::update_item_permissions_check + * @covers WP_REST_Posts_Controller::get_item + * @covers WP_REST_Posts_Controller::add_last_modified_header + * @ticket 47676 + */ + public function test_update_item_with_if_unmodified_since_precondition() { + wp_set_current_user( self::$editor_id ); + + $get_request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $get_request->set_param( 'context', 'edit' ); + $get_response = rest_get_server()->dispatch( $get_request ); + $this->assertSame( 200, $get_response->get_status() ); + $headers = $get_response->get_headers(); + $this->assertArrayHasKey( 'Last-Modified', $headers ); + $last_modified = $headers['Last-Modified']; + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->add_header( 'content-type', 'application/json' ); + $title1 = 'Same as last modified'; + $request->set_body( wp_json_encode( $this->set_post_data( array( 'title' => $title1 ) ) ) ); + $request->set_header( 'If-Unmodified-Since', $last_modified ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Update should succeed when If-Unmodified-Since matches Last-Modified.' ); + $new_data = $response->get_data(); + $this->assertSame( $title1, $new_data['title']['raw'] ); + $this->assertSame( $title1, get_post( self::$post_id )->post_title ); + + $get_response = rest_get_server()->dispatch( $get_request ); + $headers = $get_response->get_headers(); + $this->assertArrayHasKey( 'Last-Modified', $headers ); + $last_modified_after_update = $headers['Last-Modified']; + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->add_header( 'content-type', 'application/json' ); + $title2 = '1 second after last modified'; + $request->set_body( wp_json_encode( $this->set_post_data( array( 'title' => $title2 ) ) ) ); + $request->set_header( 'If-Unmodified-Since', gmdate( 'D, d M Y H:i:s', strtotime( $last_modified_after_update ) + 1 ) . ' GMT' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Update should succeed when If-Unmodified-Since is after the server modified time.' ); + $new_data = $response->get_data(); + $this->assertSame( $title2, $new_data['title']['raw'] ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->add_header( 'content-type', 'application/json' ); + $title3 = 'Should not save'; + $request->set_body( wp_json_encode( $this->set_post_data( array( 'title' => $title3 ) ) ) ); + $request->set_header( 'If-Unmodified-Since', $last_modified ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 412, $response->get_status(), 'Update should fail when If-Unmodified-Since predates current revision.' ); + $this->assertSame( $title2, get_post( self::$post_id )->post_title, 'Expected title not to update due to failed precondition.' ); + } + public function verify_post_roundtrip( $input = array(), $expected_output = array() ) { // Create the post. $request = new WP_REST_Request( 'POST', '/wp/v2/posts' );