From 28b7e0e1606c101931a647c43e0c8d1b42d0d91a Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:35:09 +1000 Subject: [PATCH 1/5] Media: Track the original attachment for edited images. Editing an image via the `wp/v2/media//edit` REST endpoint saves the result as a new attachment and leaves the edited image untouched, so a site can build up a chain: an upload, a crop of it, a crop of that crop. `parent_image` records only the immediately preceding image, so finding the image a chain started from meant walking it one attachment at a time. Each attachment created by an edit now records the ID at the top of its chain in `_wp_attachment_original_id` postmeta, inheriting it from the image being edited. `wp_get_original_attachment_id()` reads it back in a single lookup, and returns the ID it was given for attachments that were uploaded rather than edited. The attachments REST controller exposes the result as an `original_attachment` field in the `edit` context only, giving editors what they need to offer a way back to the original without telling visitors which images were made from which. Deleting an attachment clears the record from any image edited from it, so nothing is left pointing at an ID that could later be reused. Records are written going forward only; images edited before this lands are not backfilled. Fixes #65987. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/post.php | 57 ++++++ .../class-wp-rest-attachments-controller.php | 60 +++++++ .../rest-api/rest-attachments-controller.php | 165 ++++++++++++++++++ 4 files changed, 283 insertions(+) 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..0acaac18d9fb7 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. * @@ -1503,6 +1515,31 @@ public function prepare_item_for_response( $item, $request ) { } else { $data['media_details']['sizes'] = new stdClass(); } + + /* + * 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. + * + * 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. + * + * Left out when the attachment was not created by editing another one, and when + * the original no longer has a URL, which happens if its file is missing. + */ + if ( 'edit' === $request['context'] && is_array( $data['media_details'] ) ) { + $original_id = wp_get_original_attachment_id( $post->ID ); + + if ( $original_id !== (int) $post->ID ) { + $original_url = wp_get_attachment_url( $original_id ); + + if ( is_string( $original_url ) && '' !== $original_url ) { + $data['media_details']['original_attachment'] = array( + 'attachment_id' => $original_id, + 'source_url' => $original_url, + ); + } + } + } } if ( in_array( 'post', $fields, true ) ) { @@ -1797,6 +1834,29 @@ public function get_item_schema() { 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, + 'properties' => array( + 'original_attachment' => array( + 'description' => __( 'The attachment this image was originally created from by editing. Only present for images created by editing another image.' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'readonly' => true, + 'properties' => array( + 'attachment_id' => array( + 'description' => __( 'The ID of the original attachment.' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'source_url' => array( + 'description' => __( 'URL to the original attachment file.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ), + ), + ), ); $schema['properties']['post'] = array( diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 4dd0b60172cb4..1bad6a771349e 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6063,4 +6063,169 @@ 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_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['media_details'] ); + $this->assertSame( + $attachment, + $data['media_details']['original_attachment']['attachment_id'] + ); + $this->assertSame( + wp_get_attachment_url( $attachment ), + $data['media_details']['original_attachment']['source_url'] + ); + } + + /** + * @ticket 65987 + */ + public function test_uploaded_image_response_omits_the_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' ); + $data = rest_do_request( $request )->get_data(); + + $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + } + + /** + * @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['media_details'] ); + } + + /** + * An attachment recorded as its own original is a broken record, not a chain, + * so nothing should be reported for it. + * + * @ticket 65987 + */ + public function test_attachment_recorded_as_its_own_original_omits_the_field() { + 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->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + } + + /** + * @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.' + ); + } } From b6dff27231c9cfc41a0a654f3d79f1003bdc5969 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:35:19 +1000 Subject: [PATCH 2/5] Media: Move original_attachment to a top-level REST field. The field points at another attachment, and every other pointer to another entity in a media response is top level: `author`, `post`, `featured_media`. `media_details` holds the width, height, file, size and derived sizes of one image, and no references to anything else. Registering it properly also means it can be requested on its own with `_fields`, which was not possible while it was nested inside another object. Follow-up to the original commit on this branch. See #65987. --- .../class-wp-rest-attachments-controller.php | 95 ++++++++++--------- .../rest-api/rest-attachments-controller.php | 35 +++++-- 2 files changed, 76 insertions(+), 54 deletions(-) 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 0acaac18d9fb7..84921efae6b12 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 @@ -1515,37 +1515,39 @@ public function prepare_item_for_response( $item, $request ) { } else { $data['media_details']['sizes'] = new stdClass(); } + } - /* - * 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. - * - * 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. - * - * Left out when the attachment was not created by editing another one, and when - * the original no longer has a URL, which happens if its file is missing. - */ - if ( 'edit' === $request['context'] && is_array( $data['media_details'] ) ) { - $original_id = wp_get_original_attachment_id( $post->ID ); + if ( in_array( 'post', $fields, true ) ) { + $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; + } - if ( $original_id !== (int) $post->ID ) { - $original_url = wp_get_attachment_url( $original_id ); + /* + * 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. 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`. + * + * 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. + * + * Left out when the attachment was not created by editing another one, and when the + * original no longer has a URL, which happens if its file is missing. + */ + if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { + $original_id = wp_get_original_attachment_id( $post->ID ); - if ( is_string( $original_url ) && '' !== $original_url ) { - $data['media_details']['original_attachment'] = array( - 'attachment_id' => $original_id, - 'source_url' => $original_url, - ); - } + if ( $original_id !== (int) $post->ID ) { + $original_url = wp_get_attachment_url( $original_id ); + + if ( is_string( $original_url ) && '' !== $original_url ) { + $data['original_attachment'] = array( + 'attachment_id' => $original_id, + 'source_url' => $original_url, + ); } } } - if ( in_array( 'post', $fields, true ) ) { - $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; - } - if ( in_array( 'source_url', $fields, true ) ) { $data['source_url'] = wp_get_attachment_url( $post->ID ); } @@ -1834,29 +1836,6 @@ public function get_item_schema() { 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, - 'properties' => array( - 'original_attachment' => array( - 'description' => __( 'The attachment this image was originally created from by editing. Only present for images created by editing another image.' ), - 'type' => 'object', - 'context' => array( 'edit' ), - 'readonly' => true, - 'properties' => array( - 'attachment_id' => array( - 'description' => __( 'The ID of the original attachment.' ), - 'type' => 'integer', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - 'source_url' => array( - 'description' => __( 'URL to the original attachment file.' ), - 'type' => 'string', - 'format' => 'uri', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - ), - ), - ), ); $schema['properties']['post'] = array( @@ -1865,6 +1844,28 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), ); + $schema['properties']['original_attachment'] = array( + 'description' => __( 'The attachment this image was created from by editing. Only present for images created by editing another image.' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'readonly' => true, + 'properties' => array( + 'attachment_id' => array( + 'description' => __( 'The ID of the original attachment.' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'source_url' => array( + 'description' => __( 'URL to the original attachment file.' ), + 'type' => 'string', + 'format' => 'uri', + '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 1bad6a771349e..72f404d437370 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 ); @@ -6141,14 +6142,14 @@ public function test_edited_image_response_includes_the_original_attachment() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayHasKey( 'original_attachment', $data['media_details'] ); + $this->assertArrayHasKey( 'original_attachment', $data ); $this->assertSame( $attachment, - $data['media_details']['original_attachment']['attachment_id'] + $data['original_attachment']['attachment_id'] ); $this->assertSame( wp_get_attachment_url( $attachment ), - $data['media_details']['original_attachment']['source_url'] + $data['original_attachment']['source_url'] ); } @@ -6163,7 +6164,7 @@ public function test_uploaded_image_response_omits_the_original_attachment() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + $this->assertArrayNotHasKey( 'original_attachment', $data ); } /** @@ -6180,7 +6181,27 @@ public function test_view_context_omits_the_original_attachment() { $request->set_param( 'context', 'view' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + $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']['attachment_id'] ); } /** @@ -6199,7 +6220,7 @@ public function test_attachment_recorded_as_its_own_original_omits_the_field() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] ); + $this->assertArrayNotHasKey( 'original_attachment', $data ); } /** From 542dfad423aacbc18bbc70e683e44556853a65b4 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:48:14 +1000 Subject: [PATCH 3/5] Media: Cover trash and mid-chain deletion for edited image lineage. Adds tests for three cases the existing coverage left undefined. Trashing an original does not clear the record on images edited from it: `delete_attachment` only fires on permanent deletion, and keeping the record means untrashing restores the relationship intact. Editing an image whose original has been deleted starts a new chain from the image being edited, since there is no lineage left to inherit. Deleting an image from the middle of a chain leaves the images below it pointing at the start of the chain, because each one records where the chain started rather than the image directly above it. See #65987. --- .../rest-api/rest-attachments-controller.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 72f404d437370..61d5a37178b52 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6223,6 +6223,90 @@ public function test_attachment_recorded_as_its_own_original_omits_the_field() { $this->assertArrayNotHasKey( 'original_attachment', $data ); } + /** + * 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 From 93f768adaef173e325f2586e627d54b6b8e3e5d7 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 15:59:40 +1000 Subject: [PATCH 4/5] Media: Expose original_attachment as an ID with an embeddable link. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field carried an `attachment_id` and `source_url` pair. Relationships in this API are bare IDs — `post`, `parent`, `featured_media` — so it is now just the ID of the original attachment. Clients that need the original's URL or dimensions get them from a `wp:original-attachment` link, which is embeddable in the same way as a featured image: `?_embed` hydrates the whole attachment record under `_embedded`. The link is added where the request is still in scope rather than in `prepare_links()`, which cannot see it, so the link stays in the `edit` context alongside the field. See #65987. --- .../class-wp-rest-attachments-controller.php | 46 ++++++++-------- .../rest-api/rest-attachments-controller.php | 54 ++++++++++++++++--- 2 files changed, 70 insertions(+), 30 deletions(-) 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 84921efae6b12..30df9e80cc1e0 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 @@ -1523,9 +1523,11 @@ public function prepare_item_for_response( $item, $request ) { /* * 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. 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`. + * edits started from, so editors can offer a way to get back to the original. Just + * the ID, like `featured_media`: 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. @@ -1540,10 +1542,7 @@ public function prepare_item_for_response( $item, $request ) { $original_url = wp_get_attachment_url( $original_id ); if ( is_string( $original_url ) && '' !== $original_url ) { - $data['original_attachment'] = array( - 'attachment_id' => $original_id, - 'source_url' => $original_url, - ); + $data['original_attachment'] = $original_id; } } } @@ -1706,6 +1705,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 ( isset( $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. * @@ -1845,25 +1858,10 @@ public function get_item_schema() { ); $schema['properties']['original_attachment'] = array( - 'description' => __( 'The attachment this image was created from by editing. Only present for images created by editing another image.' ), - 'type' => 'object', + 'description' => __( 'The ID of the attachment this image was created from by editing. Only present for images created by editing another image.' ), + 'type' => 'integer', 'context' => array( 'edit' ), 'readonly' => true, - 'properties' => array( - 'attachment_id' => array( - 'description' => __( 'The ID of the original attachment.' ), - 'type' => 'integer', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - 'source_url' => array( - 'description' => __( 'URL to the original attachment file.' ), - 'type' => 'string', - 'format' => 'uri', - 'context' => array( 'edit' ), - 'readonly' => true, - ), - ), ); $schema['properties']['source_url'] = array( diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 61d5a37178b52..df4b63047c6c6 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6143,16 +6143,58 @@ public function test_edited_image_response_includes_the_original_attachment() { $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 ); + + $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, - $data['original_attachment']['attachment_id'] - ); - $this->assertSame( - wp_get_attachment_url( $attachment ), - $data['original_attachment']['source_url'] + $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 */ @@ -6201,7 +6243,7 @@ public function test_original_attachment_can_be_requested_on_its_own() { $this->assertArrayHasKey( 'original_attachment', $data ); $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); - $this->assertSame( $attachment, $data['original_attachment']['attachment_id'] ); + $this->assertSame( $attachment, $data['original_attachment'] ); } /** From 2c60221b9fd3d0b85f97f0e5af2cf5a687d8f8a5 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 17:20:53 +1000 Subject: [PATCH 5/5] Media: Report 0 when an image has no original attachment. The field was left out entirely for an image that was not created by editing another one. `featured_media` reports `0` for "no featured image" rather than disappearing, so this now does the same, and clients get a field of one type that is always there in the `edit` context. The stored ID is no longer checked against the original's file before being sent. Deleting an attachment already clears the ID from everything edited from it, so the check only affected originals sitting in the trash, whose files still resolve. A client following an ID that has gone stale gets no record back, which it must handle in any case. See #65987. --- .../class-wp-rest-attachments-controller.php | 27 +++++++------- .../rest-api/rest-attachments-controller.php | 35 +++++++++++++++---- 2 files changed, 41 insertions(+), 21 deletions(-) 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 30df9e80cc1e0..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 @@ -1524,27 +1524,24 @@ public function prepare_item_for_response( $item, $request ) { /* * 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`: 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`. + * 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. * - * Left out when the attachment was not created by editing another one, and when the - * original no longer has a URL, which happens if its file is missing. + * 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 ); - if ( $original_id !== (int) $post->ID ) { - $original_url = wp_get_attachment_url( $original_id ); - - if ( is_string( $original_url ) && '' !== $original_url ) { - $data['original_attachment'] = $original_id; - } - } + $data['original_attachment'] = $original_id !== (int) $post->ID ? $original_id : 0; } if ( in_array( 'source_url', $fields, true ) ) { @@ -1711,7 +1708,7 @@ public function prepare_item_for_response( $item, $request ) { * `prepare_links()` because that method cannot see the request, and this belongs * in the `edit` context only, alongside the field itself. */ - if ( isset( $data['original_attachment'] ) ) { + if ( ! empty( $data['original_attachment'] ) ) { $response->add_link( 'https://api.w.org/original-attachment', rest_url( rest_get_route_for_post( $data['original_attachment'] ) ), @@ -1858,7 +1855,7 @@ public function get_item_schema() { ); $schema['properties']['original_attachment'] = array( - 'description' => __( 'The ID of the attachment this image was created from by editing. Only present for images created by editing another image.' ), + '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, diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index df4b63047c6c6..18058b30c862f 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6088,6 +6088,19 @@ private function edit_image_and_get_new_id( $attachment_id ) { 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 */ @@ -6165,6 +6178,11 @@ public function test_original_attachment_is_embeddable() { $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'] ); @@ -6198,15 +6216,20 @@ public function test_view_context_omits_the_original_attachment_link() { /** * @ticket 65987 */ - public function test_uploaded_image_response_omits_the_original_attachment() { + 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' ); - $data = rest_do_request( $request )->get_data(); + $response = rest_do_request( $request ); - $this->assertArrayNotHasKey( 'original_attachment', $data ); + $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.' + ); } /** @@ -6248,11 +6271,11 @@ public function test_original_attachment_can_be_requested_on_its_own() { /** * An attachment recorded as its own original is a broken record, not a chain, - * so nothing should be reported for it. + * so it reports no original. * * @ticket 65987 */ - public function test_attachment_recorded_as_its_own_original_omits_the_field() { + 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 ); @@ -6262,7 +6285,7 @@ public function test_attachment_recorded_as_its_own_original_omits_the_field() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayNotHasKey( 'original_attachment', $data ); + $this->assertSame( 0, $data['original_attachment'] ); } /**