From dd1669a4fde2db197e820132ab1a98db0616d3ca Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 2 Sep 2026 14:03:27 -0700 Subject: [PATCH 1/3] REST API: Record the source attachment of an uploaded image edit. Add a parent_image parameter to the media create endpoint so an image edited in the browser and uploaded as a new attachment relates to its source the way the edit endpoint's new attachment does: the parent_image metadata entry, the source's EXIF fields the new file lacks, and an upright orientation. --- .../class-wp-rest-attachments-controller.php | 84 ++++++++++++++++ .../rest-api/rest-attachments-controller.php | 98 +++++++++++++++++++ 2 files changed, 182 insertions(+) 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..8c53fce815230 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 @@ -340,6 +340,29 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE }, ); + $args['parent_image'] = array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the attachment the uploaded image was edited from. Recorded in the new attachment\'s metadata, as the edit endpoint does.' ), + 'validate_callback' => static function ( $value, $request, $param ) { + // Re-apply the schema checks a custom validate_callback replaces. + $valid = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + if ( ! wp_attachment_is_image( (int) $value ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'Invalid parent image.' ), + array( 'status' => 400 ) + ); + } + + return true; + }, + ); + return $args; } @@ -424,6 +447,15 @@ public function create_item_permissions_check( $request ) { array( 'status' => rest_authorization_required_code() ) ); } + + // Recording an edit relation requires being allowed to edit the source, as the edit endpoint requires. + if ( ! empty( $request['parent_image'] ) && ! current_user_can( 'edit_post', (int) $request['parent_image'] ) ) { + return new WP_Error( + 'rest_cannot_edit_image', + __( 'Sorry, you are not allowed to edit this image.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } $files = $request->get_file_params(); /** @@ -503,6 +535,7 @@ public function create_item_permissions_check( $request ) { * * @since 4.7.0 * @since 7.1.0 Added the `generate_sub_sizes`, `convert_format`, and `url` parameters. + * @since 7.2.0 Added the `parent_image` parameter. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -629,6 +662,10 @@ public function create_item( $request ) { */ wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) ); + if ( ! empty( $request['parent_image'] ) ) { + $this->record_parent_image( $attachment_id, (int) $request['parent_image'] ); + } + $this->remove_client_side_media_processing_filters(); $response = $this->prepare_item_for_response( $attachment, $request ); @@ -639,6 +676,53 @@ public function create_item( $request ) { return $response; } + /** + * Records the attachment an uploaded image was edited from. + * + * Mirrors what the edit endpoint stores for the attachment it creates, so an + * image edited in the browser relates to its source the same way: the + * `parent_image` entry, the source's EXIF data for any field the new file + * lacks, and an upright orientation, since the edited pixels are upright. + * + * @since 7.2.0 + * + * @param int $attachment_id ID of the new attachment. + * @param int $parent_id ID of the attachment it was edited from. + */ + private function record_parent_image( int $attachment_id, int $parent_id ): void { + $metadata = wp_get_attachment_metadata( $attachment_id ); + if ( ! is_array( $metadata ) ) { + $metadata = array(); + } + + $parent_metadata = wp_get_attachment_metadata( $parent_id ); + if ( isset( $parent_metadata['image_meta'] ) && is_array( $parent_metadata['image_meta'] ) ) { + if ( ! isset( $metadata['image_meta'] ) || ! is_array( $metadata['image_meta'] ) ) { + $metadata['image_meta'] = array(); + } + // Merge but skip empty values, as the edit endpoint does. + foreach ( $parent_metadata['image_meta'] as $key => $value ) { + if ( empty( $metadata['image_meta'][ $key ] ) && ! empty( $value ) ) { + $metadata['image_meta'][ $key ] = $value; + } + } + } + + if ( ! empty( $metadata['image_meta']['orientation'] ) ) { + $metadata['image_meta']['orientation'] = 1; + } + + $parent_file = wp_get_original_image_path( $parent_id ); + + $metadata['parent_image'] = array( + 'attachment_id' => $parent_id, + // Path to the originally uploaded image file relative to the uploads directory. + 'file' => $parent_file ? _wp_relative_upload_path( $parent_file ) : '', + ); + + wp_update_attachment_metadata( $attachment_id, $metadata ); + } + /** * Sideloads an external image from a URL into the media library. * diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 4dd0b60172cb4..82fd5ebc65cdf 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2278,6 +2278,104 @@ public function test_image_output_format_and_progressive_defaults_in_create_resp $this->assertFalse( $data['image_save_progressive'] ); } + /** + * Uploads an image through the REST API, optionally as an edit of another attachment. + * + * @param int|null $parent_image Attachment the upload was edited from. + * @return WP_REST_Response Response. + */ + private function create_edited_image_response( ?int $parent_image = null ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-edited.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + if ( null !== $parent_image ) { + $request->set_param( 'parent_image', $parent_image ); + } + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * A client-side edit records its source attachment like the edit endpoint does. + * + * @ticket 66027 + * + * @covers WP_REST_Attachments_Controller::create_item + */ + public function test_create_item_records_parent_image(): void { + wp_set_current_user( self::$superadmin_id ); + + $parent_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + $parent_metadata = wp_get_attachment_metadata( $parent_id ); + $parent_metadata['image_meta']['credit'] = 'Photographer'; + $parent_metadata['image_meta']['orientation'] = 6; + wp_update_attachment_metadata( $parent_id, $parent_metadata ); + + $response = $this->create_edited_image_response( $parent_id ); + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $metadata = wp_get_attachment_metadata( $data['id'] ); + $expected = array( + 'attachment_id' => $parent_id, + 'file' => _wp_relative_upload_path( wp_get_original_image_path( $parent_id ) ), + ); + + $this->assertSame( $expected, $metadata['parent_image'], 'The metadata records the source attachment.' ); + $this->assertSame( $expected, $data['media_details']['parent_image'], 'The response reflects the source attachment.' ); + $this->assertSame( 'Photographer', $metadata['image_meta']['credit'], 'EXIF fields the new file lacks are copied from the source.' ); + $this->assertSame( 1, $metadata['image_meta']['orientation'], 'The edited pixels are upright.' ); + } + + /** + * @ticket 66027 + * + * @covers WP_REST_Attachments_Controller::create_item + */ + public function test_create_item_without_parent_image_records_nothing(): void { + wp_set_current_user( self::$superadmin_id ); + + $response = $this->create_edited_image_response(); + $this->assertSame( 201, $response->get_status() ); + + $metadata = wp_get_attachment_metadata( $response->get_data()['id'] ); + $this->assertArrayNotHasKey( 'parent_image', $metadata ); + } + + /** + * @ticket 66027 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_create_item_rejects_a_parent_image_that_is_not_an_image(): void { + wp_set_current_user( self::$superadmin_id ); + + $post_id = self::factory()->post->create(); + $response = $this->create_edited_image_response( $post_id ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * @ticket 66027 + * + * @covers WP_REST_Attachments_Controller::create_item_permissions_check + */ + public function test_create_item_requires_permission_to_edit_the_parent_image(): void { + wp_set_current_user( self::$superadmin_id ); + $parent_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + // Authors can upload, but cannot edit another user's attachment. + wp_set_current_user( self::$author_id ); + + $response = $this->create_edited_image_response( $parent_id ); + + $this->assertErrorResponse( 'rest_cannot_edit_image', $response, 403 ); + } + /** * Verifies image_output_format reflects an image_editor_output_format filter * that remaps JPEG to WebP, and that the filter sees the real attached From d9e3805dfb1b598aef0f07e915a8fbf5d04d86f4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 2 Sep 2026 16:59:57 -0700 Subject: [PATCH 2/3] REST API: Regenerate the QUnit REST client fixtures. The new `parent_image` argument on the media create endpoint changes the schema the QUnit client mocks, and that fixture is only refreshed by running `test_build_wp_api_client_fixtures`. Leaving it stale made every single-site PHPUnit job fail at the "Ensure version-controlled files are not modified" step, since the test rewrites the file in place during the run. --- tests/qunit/fixtures/wp-api-generated.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4a2d5a3ac7ea8..e1e38af6eadbe 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3166,6 +3166,12 @@ mockedApiResponse.Schema = { "format": "uri", "description": "URL of an external image to sideload into the media library, instead of uploading a file.", "required": false + }, + "parent_image": { + "type": "integer", + "minimum": 1, + "description": "ID of the attachment the uploaded image was edited from. Recorded in the new attachment's metadata, as the edit endpoint does.", + "required": false } } } From 6af9ddb195ee0dc1d0e0908e43c51c7d33158dd8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 3 Sep 2026 10:21:20 -0700 Subject: [PATCH 3/3] REST API: Fire wp_edited_image_metadata for client-side edits Pass the metadata recorded for a client-side edit through the same filter edit_media_item() fires for its new attachment, so hooks see edits made in the browser too. --- .../class-wp-rest-attachments-controller.php | 3 ++ .../rest-api/rest-attachments-controller.php | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) 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 8c53fce815230..8c58825fc646f 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 @@ -720,6 +720,9 @@ private function record_parent_image( int $attachment_id, int $parent_id ): void 'file' => $parent_file ? _wp_relative_upload_path( $parent_file ) : '', ); + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */ + $metadata = apply_filters( 'wp_edited_image_metadata', $metadata, $attachment_id, $parent_id ); + wp_update_attachment_metadata( $attachment_id, $metadata ); } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 82fd5ebc65cdf..4d4e911b1ccd8 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2330,6 +2330,39 @@ public function test_create_item_records_parent_image(): void { $this->assertSame( 1, $metadata['image_meta']['orientation'], 'The edited pixels are upright.' ); } + /** + * @ticket 66027 + * + * @covers WP_REST_Attachments_Controller::record_parent_image + */ + public function test_create_item_filters_the_edited_image_metadata(): void { + wp_set_current_user( self::$superadmin_id ); + + $parent_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $received = array(); + + add_filter( + 'wp_edited_image_metadata', + static function ( $new_image_meta, $new_attachment_id, $attachment_id ) use ( &$received ) { + $received = compact( 'new_image_meta', 'new_attachment_id', 'attachment_id' ); + $new_image_meta['original_root'] = $attachment_id; + return $new_image_meta; + }, + 10, + 3 + ); + + $response = $this->create_edited_image_response( $parent_id ); + $this->assertSame( 201, $response->get_status() ); + + $new_id = $response->get_data()['id']; + + $this->assertSame( $new_id, $received['new_attachment_id'], 'The filter receives the new attachment.' ); + $this->assertSame( $parent_id, $received['attachment_id'], 'The filter receives the source attachment.' ); + $this->assertSame( $parent_id, $received['new_image_meta']['parent_image']['attachment_id'], 'The filter sees the recorded source.' ); + $this->assertSame( $parent_id, wp_get_attachment_metadata( $new_id )['original_root'], 'The filtered metadata is what gets saved.' ); + } + /** * @ticket 66027 *