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 5edc8a50f0299..a651afd41b35f 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8842,6 +8842,66 @@ 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 0 for them. + * + * @since 7.2.0 + * + * @param int $attachment_id Attachment ID. + * @return int ID of the attachment the chain of edits started from, or 0 when 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 ); + + // An attachment recorded as its own edit root is a broken record rather than a chain. + if ( $edit_root_id <= 0 || $edit_root_id === (int) $attachment_id ) { + return 0; + } + + return $edit_root_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..f0f1d0e49d7c1 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,20 @@ 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. + */ + $edit_root_id = wp_get_edit_root_attachment_id( $attachment_id ); + + if ( ! $edit_root_id ) { + $edit_root_id = (int) $attachment_id; + } + + update_post_meta( $new_attachment_id, '_wp_attachment_edit_root_id', $edit_root_id ); + /** * Filters the meta data for the new image created by editing an existing image. * @@ -1509,6 +1523,15 @@ 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'] ) { + $data['edit_root'] = wp_get_edit_root_attachment_id( $post->ID ); + } + if ( in_array( 'source_url', $fields, true ) ) { $data['source_url'] = wp_get_attachment_url( $post->ID ); } @@ -1667,6 +1690,31 @@ public function prepare_item_for_response( $item, $request ) { } } + /* + * Embeddable link to the edit root, like `featured_media`. Added here rather than + * in `prepare_links()`, which cannot see the request, and gated like the parent + * controller's own links so a `_fields` request is not handed a stray `_links`. + * Like `featured_media`, the link is skipped when the edit root no longer exists + * or the user cannot read it, although the `edit_root` field still reports the ID. + */ + if ( + 'edit' === $request['context'] && + ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) + ) { + $edit_root_id = wp_get_edit_root_attachment_id( $post->ID ); + + if ( + $edit_root_id && + ( 'publish' === get_post_status( $edit_root_id ) || current_user_can( 'read_post', $edit_root_id ) ) + ) { + $response->add_link( + 'https://api.w.org/edit-root', + rest_url( rest_get_route_for_post( $edit_root_id ) ), + array( 'embeddable' => true ) + ); + } + } + /** * Filters an attachment returned from the REST API. * @@ -1805,6 +1853,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 7447ea516a127..d511b3cd1abdf 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 ); @@ -6279,4 +6280,435 @@ 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'], 'The edit root should be typed as an integer.' ); + $this->assertSame( array( 'edit' ), $schema['context'], 'The edit root should be exposed in the edit context only.' ); + $this->assertTrue( $schema['readonly'], 'The edit root should be read only.' ); + } + + /** + * @ticket 65987 + */ + public function test_get_edit_root_attachment_id_returns_zero_for_an_upload() { + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->assertSame( 0, 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, 'An edited image should report an edit root.' ); + $this->assertSame( $attachment, $data['edit_root'], 'The edit root should be the image that was edited.' ); + } + + /** + * 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, 'An edited image should carry an edit root link.' ); + $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'], 'The link should point at the edit root.' ); + $this->assertTrue( $link['attributes']['embeddable'], 'The link should be 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'], + 'Embedding should hydrate the edit root alongside the edited image.' + ); + } + + /** + * @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'], 'An uploaded image should report no 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, 'The edit root should be returned when it is the only field requested.' ); + $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); + $this->assertSame( $attachment, $data['edit_root'], 'Limiting the fields should not change the reported edit root.' ); + } + + /** + * Core leaves `_links` out of a response limited with `_fields` by not building + * its own links at all, so this link must not be the one thing that puts the + * member back. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_field_limited_request_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', 'edit' ); + $request->set_param( '_fields', 'id' ); + $links = rest_do_request( $request )->get_links(); + + $this->assertArrayNotHasKey( 'https://api.w.org/edit-root', $links ); + } + + /** + * Asking for the field is not asking for links. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_requesting_the_edit_root_field_without_links_omits_the_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', 'edit' ); + $request->set_param( '_fields', 'id,edit_root' ); + $links = rest_do_request( $request )->get_links(); + + $this->assertArrayNotHasKey( 'https://api.w.org/edit-root', $links ); + } + + /** + * A request that limits the fields but asks for links gets every link, this one + * included, whether or not it asked for the field. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_field_limited_request_keeps_the_edit_root_link_when_links_are_requested() { + 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,_links' ); + $links = rest_do_request( $request )->get_links(); + + $this->assertArrayHasKey( 'https://api.w.org/edit-root', $links ); + } + + /** + * 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, + wp_get_edit_root_attachment_id( $attachment ), + 'A record pointing at the attachment itself should resolve to no edit root.' + ); + $this->assertSame( 0, $data['edit_root'], 'The field should report no edit root.' ); + } + + /** + * The field reports the recorded ID as is, but the link is only offered when the + * edit root exists and can be read, in the same way as a featured image. + * + * @ticket 65987 + */ + public function test_missing_edit_root_keeps_the_field_but_omits_the_link() { + 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', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" ); + $request->set_param( 'context', 'edit' ); + $response = rest_do_request( $request ); + + $this->assertSame( + REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + $response->get_data()['edit_root'], + 'The field should report the recorded ID even when the edit root is gone.' + ); + $this->assertArrayNotHasKey( + 'https://api.w.org/edit-root', + $response->get_links(), + 'A missing edit root should carry no link.' + ); + } + + /** + * 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.' + ); + } }