diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..7deb9552dd3e3 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -692,6 +692,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_original_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 49ab472d37184..6ca4b588622cf 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8751,6 +8751,63 @@ function wp_get_original_image_url( $attachment_id ) { return apply_filters( 'wp_get_original_image_url', $original_image_url, $attachment_id ); } +/** + * Retrieves the ID of the attachment an edited image originally came 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 original 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 started from, or `$attachment_id` when the + * attachment was not created by editing another one. + */ +function wp_get_original_attachment_id( $attachment_id ) { + $original_id = (int) get_post_meta( $attachment_id, '_wp_attachment_original_id', true ); + + return $original_id > 0 ? $original_id : (int) $attachment_id; +} + +/** + * Clears the recorded original attachment 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 original until the trash is emptied. + * + * @since 7.2.0 + * + * @access private + * + * @param int $post_id Attachment ID being deleted. + */ +function _wp_delete_original_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 original. 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_original_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 c17193acfc916..ad3f77213a062 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 original can be + * found in one lookup from any image later in the chain. The new attachment inherits + * the original 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_original_id', + wp_get_original_attachment_id( $attachment_id ) + ); + /** * Filters the meta data for the new image created by editing an existing image. * @@ -1509,6 +1521,29 @@ public function prepare_item_for_response( $item, $request ) { $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; } + /* + * Point an image created by editing another one back at the attachment its chain of + * edits started from, so editors can offer a way to get back to the original. Just + * the ID, like `featured_media`, with `0` meaning the image was not created by + * editing another one: this describes a relationship to another attachment rather + * than anything about this image's own file, so it sits alongside `post` rather than + * inside `media_details`. The link added below lets clients fetch the original's URL + * and dimensions with `_embed`. + * + * Only sent in the `edit` context: this is for people editing the image, and it would + * otherwise tell visitors which images were made from which. + * + * The stored ID is trusted rather than checked against the original's file, because + * deleting an attachment clears it from everything edited from it. A client that + * follows a stale ID, such as one whose original is in the trash, simply gets no + * record back. + */ + if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { + $original_id = wp_get_original_attachment_id( $post->ID ); + + $data['original_attachment'] = $original_id !== (int) $post->ID ? $original_id : 0; + } + if ( in_array( 'source_url', $fields, true ) ) { $data['source_url'] = wp_get_attachment_url( $post->ID ); } @@ -1667,6 +1702,20 @@ public function prepare_item_for_response( $item, $request ) { } } + /* + * Let clients fetch the original attachment 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['original_attachment'] ) ) { + $response->add_link( + 'https://api.w.org/original-attachment', + rest_url( rest_get_route_for_post( $data['original_attachment'] ) ), + array( 'embeddable' => true ) + ); + } + /** * Filters an attachment returned from the REST API. * @@ -1805,6 +1854,13 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), ); + $schema['properties']['original_attachment'] = array( + 'description' => __( 'The ID of the attachment this image was created from by editing, or 0 if it was not created by editing another image.' ), + '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 4dd0b60172cb4..18058b30c862f 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( 'original_attachment', $properties ); $this->assertArrayHasKey( 'filename', $properties ); $this->assertArrayHasKey( 'filesize', $properties ); $this->assertArrayHasKey( 'caption', $properties ); @@ -6063,4 +6064,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_original_attachment_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/media' ); + $response = rest_get_server()->dispatch( $request ); + $schema = $response->get_data()['schema']['properties']['original_attachment']; + + $this->assertSame( 'integer', $schema['type'] ); + $this->assertSame( array( 'edit' ), $schema['context'] ); + $this->assertTrue( $schema['readonly'] ); + } + + /** + * @ticket 65987 + */ + public function test_get_original_attachment_id_returns_same_id_for_an_upload() { + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->assertSame( $attachment, wp_get_original_attachment_id( $attachment ) ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_edit_records_the_edited_image_as_the_original() { + 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_original_attachment_id( $edited ) ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_editing_an_edited_image_keeps_the_first_original() { + 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_original_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_original_attachment() { + 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( 'original_attachment', $data ); + $this->assertSame( $attachment, $data['original_attachment'] ); + } + + /** + * The response carries only the ID, so the original is offered as an embeddable + * link in the same way as a featured image. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_original_attachment_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/original-attachment', $links ); + $this->assertCount( + 1, + $links['https://api.w.org/original-attachment'], + 'The link should be added once.' + ); + + $link = $links['https://api.w.org/original-attachment'][0]; + $this->assertStringEndsWith( '/wp/v2/media/' . $attachment, $link['href'] ); + $this->assertTrue( $link['attributes']['embeddable'] ); + + // Requesting `_embed` hydrates the original alongside the edited image. + $embedded = rest_get_server()->response_to_data( $response, true ); + $this->assertSame( + $attachment, + $embedded['_embedded']['wp:original-attachment'][0]['id'] + ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_view_context_omits_the_original_attachment_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/original-attachment', $links ); + } + + /** + * @ticket 65987 + */ + public function test_uploaded_image_reports_no_original_attachment() { + 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()['original_attachment'] ); + $this->assertArrayNotHasKey( + 'https://api.w.org/original-attachment', + $response->get_links(), + 'An image with no original should carry no link.' + ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_view_context_omits_the_original_attachment() { + 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( 'original_attachment', $data ); + } + + /** + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_original_attachment_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,original_attachment' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayHasKey( 'original_attachment', $data ); + $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); + $this->assertSame( $attachment, $data['original_attachment'] ); + } + + /** + * An attachment recorded as its own original is a broken record, not a chain, + * so it reports no original. + * + * @ticket 65987 + */ + public function test_attachment_recorded_as_its_own_original_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_original_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['original_attachment'] ); + } + + /** + * 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 original restores the relationship intact. + * + * @ticket 65987 + * @requires function imagejpeg + */ + public function test_trashing_an_original_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 original should have been trashed rather than deleted.' + ); + $this->assertSame( + $attachment, + wp_get_original_attachment_id( $edited ), + 'Trashing the original should leave the record in place.' + ); + + wp_untrash_post( $attachment ); + + $this->assertSame( + $attachment, + wp_get_original_attachment_id( $edited ), + 'Untrashing the original should leave the relationship intact.' + ); + } + + /** + * Once the original 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_original_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_original_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_original_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_original_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_original_id', true ), + 'The record pointing at the deleted attachment should have been cleared.' + ); + $this->assertSame( + $unrelated, + wp_get_original_attachment_id( $unrelated_edited ), + 'An unrelated image should have kept its record.' + ); + } }