From 48cfeded77a013aa5221a5b27244913b7c170804 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:35:09 +1000 Subject: [PATCH 01/11] 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 025a371781200..7d651b90f64bf 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_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 5edc8a50f0299..26eb116546d79 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8842,6 +8842,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 9807ac9cf15b5..d1aa6c6bd6293 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 7447ea516a127..a50093cecbcd4 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6279,4 +6279,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 e2e8e09d425783264e6220813c54703c3643a89b Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:35:19 +1000 Subject: [PATCH 02/11] 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 d1aa6c6bd6293..1a7e8d8d7eb33 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 a50093cecbcd4..4672a23c880ca 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 ); @@ -6357,14 +6358,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'] ); } @@ -6379,7 +6380,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 ); } /** @@ -6396,7 +6397,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'] ); } /** @@ -6415,7 +6436,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 b3b14678a7b4a4ebd4fdc8acddf1e6c58387df28 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 12:48:14 +1000 Subject: [PATCH 03/11] 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 4672a23c880ca..a9339eb0e9e6f 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6439,6 +6439,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 62d51a4fea8f8275657944e6b5eef4aa8073c6c5 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 15:59:40 +1000 Subject: [PATCH 04/11] 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 1a7e8d8d7eb33..5839017793d33 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 a9339eb0e9e6f..f6f7e556fcb3f 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6359,16 +6359,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 */ @@ -6417,7 +6459,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 15f3ca63b1941d49a21fa22230db306e5624405c Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 31 Aug 2026 17:20:53 +1000 Subject: [PATCH 05/11] 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 5839017793d33..c3442ee4ff2d5 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 f6f7e556fcb3f..f1354ed71a728 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6304,6 +6304,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 */ @@ -6381,6 +6394,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'] ); @@ -6414,15 +6432,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.' + ); } /** @@ -6464,11 +6487,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 ); @@ -6478,7 +6501,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'] ); } /** From e5a3bda643210d8638d02022f93b60b5736af85a Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 14 Sep 2026 16:58:12 +1000 Subject: [PATCH 06/11] Media: Correct the original attachment comment and descriptions. The field comment said `original_attachment` is limited to the `edit` context so visitors cannot tell which images were made from which. `media_details` already exposes `parent_image` in the `view` and `embed` contexts, so that was not the reason. It is limited to `edit` because only editors need it. The schema description and the `@return` of `wp_get_original_attachment_id()` said a missing original meant the image was not created by editing another one. Images edited before this change have no record either, so both now say none is recorded. See #65987. --- src/wp-includes/post.php | 4 ++-- .../class-wp-rest-attachments-controller.php | 20 ++++--------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 26eb116546d79..7677260fa3de3 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8859,8 +8859,8 @@ function wp_get_original_image_url( $attachment_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. + * @return int ID of the attachment the chain of edits started from, or `$attachment_id` + * if none is recorded. */ function wp_get_original_attachment_id( $attachment_id ) { $original_id = (int) get_post_meta( $attachment_id, '_wp_attachment_original_id', true ); 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 c3442ee4ff2d5..89577d8883363 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 @@ -1522,21 +1522,9 @@ 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`, 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. + * 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( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { $original_id = wp_get_original_attachment_id( $post->ID ); @@ -1855,7 +1843,7 @@ public function get_item_schema() { ); $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.' ), + '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, From 9ffc69cefb6ded81f016c4913fb6f4bd4a7f5f5d Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 28 Sep 2026 15:03:13 +1000 Subject: [PATCH 07/11] Media: Rename the original attachment to the edit root. `wp_get_original_attachment_id()` sat next to `wp_get_original_image_path()` and `wp_get_original_image_url()`, which describe the unscaled upload of the same attachment rather than the attachment a chain of edits started from. The names now say which one they mean: - `wp_get_original_attachment_id()` -> `wp_get_edit_root_attachment_id()` - `_wp_delete_original_attachment_id()` -> `_wp_delete_edit_root_attachment_id()` - `_wp_attachment_original_id` postmeta -> `_wp_attachment_edit_root_id` - `original_attachment` REST field -> `edit_root` - `wp:original-attachment` link relation -> `wp:edit-root` No change in behaviour. See #65987. --- src/wp-includes/default-filters.php | 2 +- src/wp-includes/post.php | 20 ++-- .../class-wp-rest-attachments-controller.php | 24 ++--- .../rest-api/rest-attachments-controller.php | 100 +++++++++--------- 4 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 7d651b90f64bf..7fa12c8802a8f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -696,7 +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_original_attachment_id' ); +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 7677260fa3de3..253a30389e4cf 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8843,14 +8843,14 @@ function wp_get_original_image_url( $attachment_id ) { } /** - * Retrieves the ID of the attachment an edited image originally came from. + * 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 original in one lookup no matter how long the chain is. + * 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 @@ -8862,21 +8862,21 @@ function wp_get_original_image_url( $attachment_id ) { * @return int ID of the attachment the chain of edits started from, or `$attachment_id` * if none is recorded. */ -function wp_get_original_attachment_id( $attachment_id ) { - $original_id = (int) get_post_meta( $attachment_id, '_wp_attachment_original_id', true ); +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 $original_id > 0 ? $original_id : (int) $attachment_id; + return $edit_root_id > 0 ? $edit_root_id : (int) $attachment_id; } /** - * Clears the recorded original attachment ID from any attachment pointing at a deleted one. + * 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 original until the trash is emptied. + * trash first, attachments keep pointing at the trashed edit root until the trash is emptied. * * @since 7.2.0 * @@ -8884,7 +8884,7 @@ function wp_get_original_attachment_id( $attachment_id ) { * * @param int $post_id Attachment ID being deleted. */ -function _wp_delete_original_attachment_id( $post_id ) { +function _wp_delete_edit_root_attachment_id( $post_id ) { $post_id = (int) $post_id; if ( $post_id <= 0 ) { @@ -8892,11 +8892,11 @@ function _wp_delete_original_attachment_id( $post_id ) { } /* - * Deletes the meta from every attachment recording this ID as its original. The meta key + * 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_original_id', $post_id, true ); + delete_metadata( 'post', 0, '_wp_attachment_edit_root_id', $post_id, true ); } /** 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 89577d8883363..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 @@ -1357,15 +1357,15 @@ public function edit_media_item( $request ) { ); /* - * Record the attachment this chain of edits started from, so the original can be + * 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 original recorded on the image being edited, or that image itself when it was + * 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_original_id', - wp_get_original_attachment_id( $attachment_id ) + '_wp_attachment_edit_root_id', + wp_get_edit_root_attachment_id( $attachment_id ) ); /** @@ -1526,10 +1526,10 @@ public function prepare_item_for_response( $item, $request ) { * Edit context only, since only editors need it. * Not validated: deleting an attachment clears it from images edited from it. */ - if ( in_array( 'original_attachment', $fields, true ) && 'edit' === $request['context'] ) { - $original_id = wp_get_original_attachment_id( $post->ID ); + if ( in_array( 'edit_root', $fields, true ) && 'edit' === $request['context'] ) { + $edit_root_id = wp_get_edit_root_attachment_id( $post->ID ); - $data['original_attachment'] = $original_id !== (int) $post->ID ? $original_id : 0; + $data['edit_root'] = $edit_root_id !== (int) $post->ID ? $edit_root_id : 0; } if ( in_array( 'source_url', $fields, true ) ) { @@ -1691,15 +1691,15 @@ public function prepare_item_for_response( $item, $request ) { } /* - * Let clients fetch the original attachment in the same request with `_embed`, + * 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['original_attachment'] ) ) { + if ( ! empty( $data['edit_root'] ) ) { $response->add_link( - 'https://api.w.org/original-attachment', - rest_url( rest_get_route_for_post( $data['original_attachment'] ) ), + 'https://api.w.org/edit-root', + rest_url( rest_get_route_for_post( $data['edit_root'] ) ), array( 'embeddable' => true ) ); } @@ -1842,7 +1842,7 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), ); - $schema['properties']['original_attachment'] = array( + $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' ), diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index f1354ed71a728..f2b774039602b 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2079,7 +2079,7 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'image_quality', $properties ); $this->assertArrayHasKey( 'image_output_format', $properties ); $this->assertArrayHasKey( 'image_save_progressive', $properties ); - $this->assertArrayHasKey( 'original_attachment', $properties ); + $this->assertArrayHasKey( 'edit_root', $properties ); $this->assertArrayHasKey( 'filename', $properties ); $this->assertArrayHasKey( 'filesize', $properties ); $this->assertArrayHasKey( 'caption', $properties ); @@ -6307,10 +6307,10 @@ private function edit_image_and_get_new_id( $attachment_id ) { /** * @ticket 65987 */ - public function test_original_attachment_schema() { + 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']['original_attachment']; + $schema = $response->get_data()['schema']['properties']['edit_root']; $this->assertSame( 'integer', $schema['type'] ); $this->assertSame( array( 'edit' ), $schema['context'] ); @@ -6320,30 +6320,30 @@ public function test_original_attachment_schema() { /** * @ticket 65987 */ - public function test_get_original_attachment_id_returns_same_id_for_an_upload() { + 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_original_attachment_id( $attachment ) ); + $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_original() { + 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_original_attachment_id( $edited ) ); + $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_original() { + 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 ); @@ -6352,7 +6352,7 @@ public function test_editing_an_edited_image_keeps_the_first_original() { $this->assertSame( $attachment, - wp_get_original_attachment_id( $edited_again ), + wp_get_edit_root_attachment_id( $edited_again ), 'An edit of an edit should still point at the image the chain started from.' ); } @@ -6361,7 +6361,7 @@ public function test_editing_an_edited_image_keeps_the_first_original() { * @ticket 65987 * @requires function imagejpeg */ - public function test_edited_image_response_includes_the_original_attachment() { + 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 ); @@ -6371,18 +6371,18 @@ 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 ); - $this->assertSame( $attachment, $data['original_attachment'] ); + $this->assertArrayHasKey( 'edit_root', $data ); + $this->assertSame( $attachment, $data['edit_root'] ); } /** - * The response carries only the ID, so the original is offered as an embeddable + * 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_original_attachment_is_embeddable() { + public function test_edit_root_is_embeddable() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); @@ -6393,22 +6393,22 @@ public function test_original_attachment_is_embeddable() { $response = rest_do_request( $request ); $links = $response->get_links(); - $this->assertArrayHasKey( 'https://api.w.org/original-attachment', $links ); + $this->assertArrayHasKey( 'https://api.w.org/edit-root', $links ); $this->assertCount( 1, - $links['https://api.w.org/original-attachment'], + $links['https://api.w.org/edit-root'], 'The link should be added once.' ); - $link = $links['https://api.w.org/original-attachment'][0]; + $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 original alongside the edited image. + // 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:original-attachment'][0]['id'] + $embedded['_embedded']['wp:edit-root'][0]['id'] ); } @@ -6416,7 +6416,7 @@ public function test_original_attachment_is_embeddable() { * @ticket 65987 * @requires function imagejpeg */ - public function test_view_context_omits_the_original_attachment_link() { + 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 ); @@ -6426,13 +6426,13 @@ public function test_view_context_omits_the_original_attachment_link() { $request->set_param( 'context', 'view' ); $links = rest_do_request( $request )->get_links(); - $this->assertArrayNotHasKey( 'https://api.w.org/original-attachment', $links ); + $this->assertArrayNotHasKey( 'https://api.w.org/edit-root', $links ); } /** * @ticket 65987 */ - public function test_uploaded_image_reports_no_original_attachment() { + 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 ); @@ -6440,11 +6440,11 @@ public function test_uploaded_image_reports_no_original_attachment() { $request->set_param( 'context', 'edit' ); $response = rest_do_request( $request ); - $this->assertSame( 0, $response->get_data()['original_attachment'] ); + $this->assertSame( 0, $response->get_data()['edit_root'] ); $this->assertArrayNotHasKey( - 'https://api.w.org/original-attachment', + 'https://api.w.org/edit-root', $response->get_links(), - 'An image with no original should carry no link.' + 'An image with no edit root should carry no link.' ); } @@ -6452,7 +6452,7 @@ public function test_uploaded_image_reports_no_original_attachment() { * @ticket 65987 * @requires function imagejpeg */ - public function test_view_context_omits_the_original_attachment() { + 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 ); @@ -6462,14 +6462,14 @@ 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 ); + $this->assertArrayNotHasKey( 'edit_root', $data ); } /** * @ticket 65987 * @requires function imagejpeg */ - public function test_original_attachment_can_be_requested_on_its_own() { + 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 ); @@ -6477,42 +6477,42 @@ public function test_original_attachment_can_be_requested_on_its_own() { $request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" ); $request->set_param( 'context', 'edit' ); - $request->set_param( '_fields', 'id,original_attachment' ); + $request->set_param( '_fields', 'id,edit_root' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayHasKey( 'original_attachment', $data ); + $this->assertArrayHasKey( 'edit_root', $data ); $this->assertArrayNotHasKey( 'media_details', $data, 'Only the requested fields should be returned.' ); - $this->assertSame( $attachment, $data['original_attachment'] ); + $this->assertSame( $attachment, $data['edit_root'] ); } /** - * An attachment recorded as its own original is a broken record, not a chain, - * so it reports no original. + * 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_original_reports_none() { + 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_original_id', $attachment ); + 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['original_attachment'] ); + $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 original restores the relationship intact. + * the edit root restores the relationship intact. * * @ticket 65987 * @requires function imagejpeg */ - public function test_trashing_an_original_keeps_the_record_on_the_images_edited_from_it() { + 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 ); @@ -6523,31 +6523,31 @@ public function test_trashing_an_original_keeps_the_record_on_the_images_edited_ $this->assertSame( 'trash', get_post_status( $attachment ), - 'The original should have been trashed rather than deleted.' + 'The edit root 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_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_original_attachment_id( $edited ), - 'Untrashing the original should leave the relationship intact.' + wp_get_edit_root_attachment_id( $edited ), + 'Untrashing the edit root should leave the relationship intact.' ); } /** - * Once the original is gone its record is cleared, so a further edit has no + * 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_original_is_deleted_starts_a_new_chain() { + 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 ); @@ -6559,7 +6559,7 @@ public function test_editing_again_after_the_original_is_deleted_starts_a_new_ch $this->assertSame( $edited, - wp_get_original_attachment_id( $edited_again ), + wp_get_edit_root_attachment_id( $edited_again ), 'The new image should point at the image it was edited from.' ); } @@ -6583,7 +6583,7 @@ public function test_deleting_a_middle_image_leaves_the_rest_of_the_chain_intact $this->assertSame( $attachment, - wp_get_original_attachment_id( $edited_again ), + wp_get_edit_root_attachment_id( $edited_again ), 'The remaining image should still point at the start of the chain.' ); } @@ -6592,7 +6592,7 @@ public function test_deleting_a_middle_image_leaves_the_rest_of_the_chain_intact * @ticket 65987 * @requires function imagejpeg */ - public function test_deleting_an_original_clears_it_from_the_images_edited_from_it() { + 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 ); @@ -6605,12 +6605,12 @@ public function test_deleting_an_original_clears_it_from_the_images_edited_from_ $this->assertSame( '', - get_post_meta( $edited, '_wp_attachment_original_id', true ), + 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_original_attachment_id( $unrelated_edited ), + wp_get_edit_root_attachment_id( $unrelated_edited ), 'An unrelated image should have kept its record.' ); } From ea3620edb9f36fea41d868eb7030441e28a24fea Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 2 Oct 2026 13:35:10 +1000 Subject: [PATCH 08/11] Media: Skip the edit root link when links are not requested. A response limited with `_fields` carries no `_links` member unless the request asks for `_links` or `_embedded`, because the posts controller builds no links at all in that case. The edit root link was added outside that check and keyed off the response field, so `_fields=id,edit_root` came back with a `_links` member holding this one link alone, and `_fields=id,_links` came back without it while every other link was there. The link is now gated on the same check the parent controller uses for its own links, and reads the edit root itself rather than the prepared field, so asking for links no longer depends on asking for the field. Follows https://github.com/WordPress/gutenberg/pull/81803. The plugin also treats a bare `_embed` parameter as a request for links. Core does not: `get_fields_for_response()` drops `_embedded` from a `_fields` list that omits it, and core's own links stay out of that response too. See #65987. --- .../class-wp-rest-attachments-controller.php | 26 +++++--- .../rest-api/rest-attachments-controller.php | 63 +++++++++++++++++++ 2 files changed, 79 insertions(+), 10 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 e275a3ecf20e7..a6b8e94f177b9 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 @@ -1691,17 +1691,23 @@ 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. + * 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`. */ - 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 ) - ); + 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 !== (int) $post->ID ) { + $response->add_link( + 'https://api.w.org/edit-root', + rest_url( rest_get_route_for_post( $edit_root_id ) ), + array( 'embeddable' => true ) + ); + } } /** diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index f2b774039602b..1ba901310e8cc 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6485,6 +6485,69 @@ public function test_edit_root_can_be_requested_on_its_own() { $this->assertSame( $attachment, $data['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. From 4603e92beb9f8b3ee985b509a971db28400098b7 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 2 Oct 2026 16:41:12 +1000 Subject: [PATCH 09/11] Media: Skip the edit root link when the edit root cannot be read. The `wp:edit-root` link promises that `_embed` can fetch the edit root, but the recorded ID is not checked, so an edit root that no longer exists, or one the user cannot read, was still offered as a link. The link now uses the same check as the `featured_media` link: it is added only when the edit root is published or the user can read it. The `edit_root` field still reports the recorded ID as is. Follows https://github.com/WordPress/gutenberg/pull/81803. See #65987. --- .../class-wp-rest-attachments-controller.php | 7 +++++- .../rest-api/rest-attachments-controller.php | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) 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 a6b8e94f177b9..fe97a01a65ce2 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 @@ -1694,6 +1694,8 @@ 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'] && @@ -1701,7 +1703,10 @@ public function prepare_item_for_response( $item, $request ) { ) { $edit_root_id = wp_get_edit_root_attachment_id( $post->ID ); - if ( $edit_root_id !== (int) $post->ID ) { + if ( + $edit_root_id !== (int) $post->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 ) ), diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 1ba901310e8cc..28320bafe7253 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6567,6 +6567,30 @@ public function test_attachment_recorded_as_its_own_edit_root_reports_none() { $this->assertSame( 0, $data['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'] ); + $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 From fecd8bd37b095b2771eb262ed16c58cc7cbbb132 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 6 Oct 2026 14:24:16 +1100 Subject: [PATCH 10/11] Media: Add assertion messages to the edit root tests. Every assertion in a test that makes more than one now says what it checks, so a failure names the behaviour that broke rather than only the line it broke on. See #65987. --- .../rest-api/rest-attachments-controller.php | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 28320bafe7253..da89b31c7f727 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6312,9 +6312,9 @@ public function test_edit_root_schema() { $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'] ); + $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.' ); } /** @@ -6371,8 +6371,8 @@ public function test_edited_image_response_includes_the_edit_root() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayHasKey( 'edit_root', $data ); - $this->assertSame( $attachment, $data['edit_root'] ); + $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.' ); } /** @@ -6393,7 +6393,7 @@ public function test_edit_root_is_embeddable() { $response = rest_do_request( $request ); $links = $response->get_links(); - $this->assertArrayHasKey( 'https://api.w.org/edit-root', $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'], @@ -6401,14 +6401,15 @@ public function test_edit_root_is_embeddable() { ); $link = $links['https://api.w.org/edit-root'][0]; - $this->assertStringEndsWith( '/wp/v2/media/' . $attachment, $link['href'] ); - $this->assertTrue( $link['attributes']['embeddable'] ); + $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'] + $embedded['_embedded']['wp:edit-root'][0]['id'], + 'Embedding should hydrate the edit root alongside the edited image.' ); } @@ -6440,7 +6441,7 @@ public function test_uploaded_image_reports_no_edit_root() { $request->set_param( 'context', 'edit' ); $response = rest_do_request( $request ); - $this->assertSame( 0, $response->get_data()['edit_root'] ); + $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(), @@ -6480,9 +6481,9 @@ public function test_edit_root_can_be_requested_on_its_own() { $request->set_param( '_fields', 'id,edit_root' ); $data = rest_do_request( $request )->get_data(); - $this->assertArrayHasKey( 'edit_root', $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'] ); + $this->assertSame( $attachment, $data['edit_root'], 'Limiting the fields should not change the reported edit root.' ); } /** @@ -6583,7 +6584,11 @@ public function test_missing_edit_root_keeps_the_field_but_omits_the_link() { $request->set_param( 'context', 'edit' ); $response = rest_do_request( $request ); - $this->assertSame( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, $response->get_data()['edit_root'] ); + $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(), From 6c95f24c068d3ccd87545f389ca5a3bf419616d8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 6 Oct 2026 14:24:30 +1100 Subject: [PATCH 11/11] Media: Report no edit root as 0 rather than the attachment's own ID. `wp_get_edit_root_attachment_id()` returned the ID it was given when an attachment had no recorded edit root, so a caller had to compare the result against the ID it passed in to tell "has an edit root" from "is its own edit root". It now returns 0, which is what `get_post_thumbnail_id()` and `wp_get_post_parent_id()` do for nothing, and what the REST field already reported. A record pointing at the attachment itself resolves to 0 too, so that broken record is handled where the lookup happens rather than by each caller. `edit_media_item()` relied on the old return value to seed the first edit in a chain and now falls back to the edited attachment explicitly. The REST field is the function's result as it stands, and the link is offered when that result is non-zero. See #65987. --- src/wp-includes/post.php | 13 ++++++++----- .../class-wp-rest-attachments-controller.php | 18 +++++++++--------- .../rest-api/rest-attachments-controller.php | 11 ++++++++--- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 253a30389e4cf..a651afd41b35f 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8853,19 +8853,22 @@ function wp_get_original_image_url( $attachment_id ) { * 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. + * 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 `$attachment_id` - * if none is recorded. + * @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 ); - return $edit_root_id > 0 ? $edit_root_id : (int) $attachment_id; + // 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; } /** 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 fe97a01a65ce2..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 @@ -1362,11 +1362,13 @@ public function edit_media_item( $request ) { * 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 ) - ); + $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. @@ -1527,9 +1529,7 @@ public function prepare_item_for_response( $item, $request ) { * 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; + $data['edit_root'] = wp_get_edit_root_attachment_id( $post->ID ); } if ( in_array( 'source_url', $fields, true ) ) { @@ -1704,7 +1704,7 @@ public function prepare_item_for_response( $item, $request ) { $edit_root_id = wp_get_edit_root_attachment_id( $post->ID ); if ( - $edit_root_id !== (int) $post->ID && + $edit_root_id && ( 'publish' === get_post_status( $edit_root_id ) || current_user_can( 'read_post', $edit_root_id ) ) ) { $response->add_link( diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index da89b31c7f727..d511b3cd1abdf 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -6320,10 +6320,10 @@ public function test_edit_root_schema() { /** * @ticket 65987 */ - public function test_get_edit_root_attachment_id_returns_same_id_for_an_upload() { + 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( $attachment, wp_get_edit_root_attachment_id( $attachment ) ); + $this->assertSame( 0, wp_get_edit_root_attachment_id( $attachment ) ); } /** @@ -6565,7 +6565,12 @@ public function test_attachment_recorded_as_its_own_edit_root_reports_none() { $request->set_param( 'context', 'edit' ); $data = rest_do_request( $request )->get_data(); - $this->assertSame( 0, $data['edit_root'] ); + $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.' ); } /**