diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 025a371781200..7fa12c8802a8f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -696,6 +696,7 @@ add_action( 'customize_controls_enqueue_scripts', 'wp_plupload_default_settings' ); add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 ); add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' ); +add_action( 'delete_attachment', '_wp_delete_edit_root_attachment_id' ); // Client-side media processing. add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index aeea4e5bafac2..f2806246b6999 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8826,6 +8826,63 @@ function wp_get_original_image_url( $attachment_id ) { return apply_filters( 'wp_get_original_image_url', $original_image_url, $attachment_id ); } +/** + * Retrieves the edit root of an attachment: the attachment its chain of edits started from. + * + * Editing an image through the `wp/v2/media//edit` REST endpoint does not change the + * image that was edited. It saves the result as a brand new attachment, so a site can end + * up with a chain of attachments: an upload, a crop of it, a crop of that crop, and so on. + * + * Every attachment created that way stores the ID of the attachment at the top of its chain, + * so this function can find the edit root in one lookup no matter how long the chain is. + * + * Attachments that were uploaded rather than created by editing have no chain of their own, + * and this returns the ID that was passed in. To tell the two cases apart, compare the + * result against that ID. + * + * @since 7.2.0 + * + * @param int $attachment_id Attachment ID. + * @return int ID of the attachment the chain of edits started from, or `$attachment_id` + * if none is recorded. + */ +function wp_get_edit_root_attachment_id( $attachment_id ) { + $edit_root_id = (int) get_post_meta( $attachment_id, '_wp_attachment_edit_root_id', true ); + + return $edit_root_id > 0 ? $edit_root_id : (int) $attachment_id; +} + +/** + * Clears the recorded edit root ID from any attachment pointing at a deleted one. + * + * Without this, attachments created by editing the deleted image would keep pointing at an + * ID that no longer exists, and could later point at an unrelated attachment if WordPress + * reuses that ID. + * + * This only runs when an attachment is deleted for good. On sites where media goes to the + * trash first, attachments keep pointing at the trashed edit root until the trash is emptied. + * + * @since 7.2.0 + * + * @access private + * + * @param int $post_id Attachment ID being deleted. + */ +function _wp_delete_edit_root_attachment_id( $post_id ) { + $post_id = (int) $post_id; + + if ( $post_id <= 0 ) { + return; + } + + /* + * Deletes the meta from every attachment recording this ID as its edit root. The meta key + * is indexed, so this only scans the rows for attachments created by editing an image, + * and it avoids searching the serialized attachment metadata for the ID. + */ + delete_metadata( 'post', 0, '_wp_attachment_edit_root_id', $post_id, true ); +} + /** * Filters callback which sets the status of an untrashed post to its previous status. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 9807ac9cf15b5..e275a3ecf20e7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1356,6 +1356,18 @@ public function edit_media_item( $request ) { 'file' => _wp_relative_upload_path( $image_file ), ); + /* + * Record the attachment this chain of edits started from, so the edit root can be + * found in one lookup from any image later in the chain. The new attachment inherits + * the edit root recorded on the image being edited, or that image itself when it was + * uploaded rather than edited. + */ + update_post_meta( + $new_attachment_id, + '_wp_attachment_edit_root_id', + wp_get_edit_root_attachment_id( $attachment_id ) + ); + /** * Filters the meta data for the new image created by editing an existing image. * @@ -1509,6 +1521,17 @@ public function prepare_item_for_response( $item, $request ) { $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; } + /* + * ID of the attachment this image's chain of edits started from, or 0. + * Edit context only, since only editors need it. + * Not validated: deleting an attachment clears it from images edited from it. + */ + if ( in_array( 'edit_root', $fields, true ) && 'edit' === $request['context'] ) { + $edit_root_id = wp_get_edit_root_attachment_id( $post->ID ); + + $data['edit_root'] = $edit_root_id !== (int) $post->ID ? $edit_root_id : 0; + } + if ( in_array( 'source_url', $fields, true ) ) { $data['source_url'] = wp_get_attachment_url( $post->ID ); } @@ -1667,6 +1690,20 @@ public function prepare_item_for_response( $item, $request ) { } } + /* + * Let clients fetch the edit root in the same request with `_embed`, + * the way `featured_media` is paired with its own link. Added here rather than in + * `prepare_links()` because that method cannot see the request, and this belongs + * in the `edit` context only, alongside the field itself. + */ + if ( ! empty( $data['edit_root'] ) ) { + $response->add_link( + 'https://api.w.org/edit-root', + rest_url( rest_get_route_for_post( $data['edit_root'] ) ), + array( 'embeddable' => true ) + ); + } + /** * Filters an attachment returned from the REST API. * @@ -1805,6 +1842,13 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), ); + $schema['properties']['edit_root'] = array( + 'description' => __( 'The ID of the attachment this attachment\'s chain of edits started from, or 0 if none is recorded.' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ); + $schema['properties']['source_url'] = array( 'description' => __( 'URL to the original attachment file.' ), 'type' => 'string', diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 282c0f6b95e22..d12e63cf025bf 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2072,13 +2072,14 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 35, $properties ); + $this->assertCount( 36, $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'alt_text', $properties ); $this->assertArrayHasKey( 'exif_orientation', $properties ); $this->assertArrayHasKey( 'image_quality', $properties ); $this->assertArrayHasKey( 'image_output_format', $properties ); $this->assertArrayHasKey( 'image_save_progressive', $properties ); + $this->assertArrayHasKey( 'edit_root', $properties ); $this->assertArrayHasKey( 'filename', $properties ); $this->assertArrayHasKey( 'filesize', $properties ); $this->assertArrayHasKey( 'caption', $properties ); @@ -6261,4 +6262,338 @@ public function test_url_arg_rejects_unsafe_urls() { $this->assertSame( 400, $result->get_error_data()['status'] ); } } + + /** + * Edits an image and returns the ID of the attachment the edit created. + * + * @param int $attachment_id Attachment to edit. + * @return int New attachment ID. + */ + private function edit_image_and_get_new_id( $attachment_id ) { + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/edit" ); + $request->set_body_params( + array( + 'rotation' => 60, + 'src' => wp_get_attachment_image_url( $attachment_id, 'full' ), + ) + ); + + $response = rest_do_request( $request ); + $this->assertSame( 201, $response->get_status(), 'The image edit should have succeeded.' ); + + $data = $response->get_data(); + + return $data['id']; + } + + /** + * @ticket 65987 + */ + public function test_edit_root_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/media' ); + $response = rest_get_server()->dispatch( $request ); + $schema = $response->get_data()['schema']['properties']['edit_root']; + + $this->assertSame( 'integer', $schema['type'] ); + $this->assertSame( array( 'edit' ), $schema['context'] ); + $this->assertTrue( $schema['readonly'] ); + } + + /** + * @ticket 65987 + */ + public function test_get_edit_root_attachment_id_returns_same_id_for_an_upload() { + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->assertSame( $attachment, wp_get_edit_root_attachment_id( $attachment ) ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_edit_records_the_edited_image_as_the_edit_root() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $this->assertSame( $attachment, wp_get_edit_root_attachment_id( $edited ) ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_editing_an_edited_image_keeps_the_first_edit_root() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + $edited_again = $this->edit_image_and_get_new_id( $edited ); + + $this->assertSame( + $attachment, + wp_get_edit_root_attachment_id( $edited_again ), + 'An edit of an edit should still point at the image the chain started from.' + ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_edited_image_response_includes_the_edit_root() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'edit' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayHasKey( 'edit_root', $data ); + $this->assertSame( $attachment, $data['edit_root'] ); + } + + /** + * The response carries only the ID, so the edit root is offered as an embeddable + * link in the same way as a featured image. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_edit_root_is_embeddable() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'edit' ); + $response = rest_do_request( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'https://api.w.org/edit-root', $links ); + $this->assertCount( + 1, + $links['https://api.w.org/edit-root'], + 'The link should be added once.' + ); + + $link = $links['https://api.w.org/edit-root'][0]; + $this->assertStringEndsWith( '/wp/v2/media/' . $attachment, $link['href'] ); + $this->assertTrue( $link['attributes']['embeddable'] ); + + // Requesting `_embed` hydrates the edit root alongside the edited image. + $embedded = rest_get_server()->response_to_data( $response, true ); + $this->assertSame( + $attachment, + $embedded['_embedded']['wp:edit-root'][0]['id'] + ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_view_context_omits_the_edit_root_link() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'view' ); + $links = rest_do_request( $request )->get_links(); + + $this->assertArrayNotHasKey( 'https://api.w.org/edit-root', $links ); + } + + /** + * @ticket 65987 + */ + public function test_uploaded_image_reports_no_edit_root() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" ); + $request->set_param( 'context', 'edit' ); + $response = rest_do_request( $request ); + + $this->assertSame( 0, $response->get_data()['edit_root'] ); + $this->assertArrayNotHasKey( + 'https://api.w.org/edit-root', + $response->get_links(), + 'An image with no edit root should carry no link.' + ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_view_context_omits_the_edit_root() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'view' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayNotHasKey( 'edit_root', $data ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_edit_root_can_be_requested_on_its_own() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); + $request->set_param( 'context', 'edit' ); + $request->set_param( '_fields', 'id,edit_root' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayHasKey( 'edit_root', $data ); + $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); + $this->assertSame( $attachment, $data['edit_root'] ); + } + + /** + * An attachment recorded as its own edit root is a broken record, not a chain, + * so it reports no edit root. + * + * @ticket 65987 + */ + public function test_attachment_recorded_as_its_own_edit_root_reports_none() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + update_post_meta( $attachment, '_wp_attachment_edit_root_id', $attachment ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" ); + $request->set_param( 'context', 'edit' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertSame( 0, $data['edit_root'] ); + } + + /** + * Trashing is not deleting. `delete_attachment` does not fire for a trashed + * attachment, and the record is deliberately left in place so that untrashing + * the edit root restores the relationship intact. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_trashing_an_edit_root_keeps_the_record_on_the_images_edited_from_it() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + + wp_trash_post( $attachment ); + + $this->assertSame( + 'trash', + get_post_status( $attachment ), + 'The edit root should have been trashed rather than deleted.' + ); + $this->assertSame( + $attachment, + wp_get_edit_root_attachment_id( $edited ), + 'Trashing the edit root should leave the record in place.' + ); + + wp_untrash_post( $attachment ); + + $this->assertSame( + $attachment, + wp_get_edit_root_attachment_id( $edited ), + 'Untrashing the edit root should leave the relationship intact.' + ); + } + + /** + * Once the edit root is gone its record is cleared, so a further edit has no + * lineage to inherit and starts a new chain from the image being edited. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_editing_again_after_the_edit_root_is_deleted_starts_a_new_chain() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + + wp_delete_attachment( $attachment, true ); + + $edited_again = $this->edit_image_and_get_new_id( $edited ); + + $this->assertSame( + $edited, + wp_get_edit_root_attachment_id( $edited_again ), + 'The new image should point at the image it was edited from.' + ); + } + + /** + * Deleting an image from the middle of a chain does not orphan the images + * edited from it, because every image records the start of the chain rather + * than the image directly above it. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_deleting_a_middle_image_leaves_the_rest_of_the_chain_intact() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + $edited_again = $this->edit_image_and_get_new_id( $edited ); + + wp_delete_attachment( $edited, true ); + + $this->assertSame( + $attachment, + wp_get_edit_root_attachment_id( $edited_again ), + 'The remaining image should still point at the start of the chain.' + ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_deleting_an_edit_root_clears_it_from_the_images_edited_from_it() { + wp_set_current_user( self::$superadmin_id ); + + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + $edited = $this->edit_image_and_get_new_id( $attachment ); + + $unrelated = self::factory()->attachment->create_upload_object( self::$test_file ); + $unrelated_edited = $this->edit_image_and_get_new_id( $unrelated ); + + wp_delete_attachment( $attachment, true ); + + $this->assertSame( + '', + get_post_meta( $edited, '_wp_attachment_edit_root_id', true ), + 'The record pointing at the deleted attachment should have been cleared.' + ); + $this->assertSame( + $unrelated, + wp_get_edit_root_attachment_id( $unrelated_edited ), + 'An unrelated image should have kept its record.' + ); + } }