Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 );
Expand All @@ -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.
*
Expand Down
98 changes: 98 additions & 0 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -2278,6 +2278,104 @@
$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
Expand Down Expand Up @@ -3218,7 +3316,7 @@
* @requires extension exif
* @requires function imagejpeg
*/
public function test_edit_image_rotate_with_unbaked_exif_orientation() {

Check warning on line 3319 in tests/phpunit/tests/rest-api/rest-attachments-controller.php

View workflow job for this annotation

GitHub Actions / PHP 8.5 / MySQL 8.4 (test reporting enabled)

Slow PHPUnit test

WP_Test_REST_Attachments_Controller::test_edit_image_rotate_with_unbaked_exif_orientation took 1.098438s
wp_set_current_user( self::$superadmin_id );
$attachment = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/test-image-rotated-90ccw.jpg' );

Expand Down
6 changes: 6 additions & 0 deletions tests/qunit/fixtures/wp-api-generated.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
Loading