Skip to content

Commit f271a85

Browse files
committed
Media: Track the original attachment for edited images.
Editing an image via the `wp/v2/media/<id>/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 `media_details.original_attachment` 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. Props ramonjd. Fixes #65987.
1 parent 4a8a86b commit f271a85

4 files changed

Lines changed: 283 additions & 0 deletions

File tree

‎src/wp-includes/default-filters.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@
692692
add_action( 'customize_controls_enqueue_scripts', 'wp_plupload_default_settings' );
693693
add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 );
694694
add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' );
695+
add_action( 'delete_attachment', '_wp_delete_original_attachment_id' );
695696

696697
// Client-side media processing.
697698
add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' );

‎src/wp-includes/post.php‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8751,6 +8751,63 @@ function wp_get_original_image_url( $attachment_id ) {
87518751
return apply_filters( 'wp_get_original_image_url', $original_image_url, $attachment_id );
87528752
}
87538753

8754+
/**
8755+
* Retrieves the ID of the attachment an edited image originally came from.
8756+
*
8757+
* Editing an image through the `wp/v2/media/<id>/edit` REST endpoint does not change the
8758+
* image that was edited. It saves the result as a brand new attachment, so a site can end
8759+
* up with a chain of attachments: an upload, a crop of it, a crop of that crop, and so on.
8760+
*
8761+
* Every attachment created that way stores the ID of the attachment at the top of its chain,
8762+
* so this function can find the original in one lookup no matter how long the chain is.
8763+
*
8764+
* Attachments that were uploaded rather than created by editing have no chain of their own,
8765+
* and this returns the ID that was passed in. To tell the two cases apart, compare the
8766+
* result against that ID.
8767+
*
8768+
* @since 7.2.0
8769+
*
8770+
* @param int $attachment_id Attachment ID.
8771+
* @return int ID of the attachment the chain started from, or `$attachment_id` when the
8772+
* attachment was not created by editing another one.
8773+
*/
8774+
function wp_get_original_attachment_id( $attachment_id ) {
8775+
$original_id = (int) get_post_meta( $attachment_id, '_wp_attachment_original_id', true );
8776+
8777+
return $original_id > 0 ? $original_id : (int) $attachment_id;
8778+
}
8779+
8780+
/**
8781+
* Clears the recorded original attachment ID from any attachment pointing at a deleted one.
8782+
*
8783+
* Without this, attachments created by editing the deleted image would keep pointing at an
8784+
* ID that no longer exists, and could later point at an unrelated attachment if WordPress
8785+
* reuses that ID.
8786+
*
8787+
* This only runs when an attachment is deleted for good. On sites where media goes to the
8788+
* trash first, attachments keep pointing at the trashed original until the trash is emptied.
8789+
*
8790+
* @since 7.2.0
8791+
*
8792+
* @access private
8793+
*
8794+
* @param int $post_id Attachment ID being deleted.
8795+
*/
8796+
function _wp_delete_original_attachment_id( $post_id ) {
8797+
$post_id = (int) $post_id;
8798+
8799+
if ( $post_id <= 0 ) {
8800+
return;
8801+
}
8802+
8803+
/*
8804+
* Deletes the meta from every attachment recording this ID as its original. The meta key
8805+
* is indexed, so this only scans the rows for attachments created by editing an image,
8806+
* and it avoids searching the serialized attachment metadata for the ID.
8807+
*/
8808+
delete_metadata( 'post', 0, '_wp_attachment_original_id', $post_id, true );
8809+
}
8810+
87548811
/**
87558812
* Filters callback which sets the status of an untrashed post to its previous status.
87568813
*

‎src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,18 @@ public function edit_media_item( $request ) {
13561356
'file' => _wp_relative_upload_path( $image_file ),
13571357
);
13581358

1359+
/*
1360+
* Record the attachment this chain of edits started from, so the original can be
1361+
* found in one lookup from any image later in the chain. The new attachment inherits
1362+
* the original recorded on the image being edited, or that image itself when it was
1363+
* uploaded rather than edited.
1364+
*/
1365+
update_post_meta(
1366+
$new_attachment_id,
1367+
'_wp_attachment_original_id',
1368+
wp_get_original_attachment_id( $attachment_id )
1369+
);
1370+
13591371
/**
13601372
* Filters the meta data for the new image created by editing an existing image.
13611373
*
@@ -1503,6 +1515,31 @@ public function prepare_item_for_response( $item, $request ) {
15031515
} else {
15041516
$data['media_details']['sizes'] = new stdClass();
15051517
}
1518+
1519+
/*
1520+
* Point an image created by editing another one back at the attachment its chain
1521+
* of edits started from, so editors can offer a way to get back to the original.
1522+
*
1523+
* Only sent in the `edit` context: this is for people editing the image, and it
1524+
* would otherwise tell visitors which images were made from which.
1525+
*
1526+
* Left out when the attachment was not created by editing another one, and when
1527+
* the original no longer has a URL, which happens if its file is missing.
1528+
*/
1529+
if ( 'edit' === $request['context'] && is_array( $data['media_details'] ) ) {
1530+
$original_id = wp_get_original_attachment_id( $post->ID );
1531+
1532+
if ( $original_id !== (int) $post->ID ) {
1533+
$original_url = wp_get_attachment_url( $original_id );
1534+
1535+
if ( is_string( $original_url ) && '' !== $original_url ) {
1536+
$data['media_details']['original_attachment'] = array(
1537+
'attachment_id' => $original_id,
1538+
'source_url' => $original_url,
1539+
);
1540+
}
1541+
}
1542+
}
15061543
}
15071544

15081545
if ( in_array( 'post', $fields, true ) ) {
@@ -1797,6 +1834,29 @@ public function get_item_schema() {
17971834
'type' => 'object',
17981835
'context' => array( 'view', 'edit', 'embed' ),
17991836
'readonly' => true,
1837+
'properties' => array(
1838+
'original_attachment' => array(
1839+
'description' => __( 'The attachment this image was originally created from by editing. Only present for images created by editing another image.' ),
1840+
'type' => 'object',
1841+
'context' => array( 'edit' ),
1842+
'readonly' => true,
1843+
'properties' => array(
1844+
'attachment_id' => array(
1845+
'description' => __( 'The ID of the original attachment.' ),
1846+
'type' => 'integer',
1847+
'context' => array( 'edit' ),
1848+
'readonly' => true,
1849+
),
1850+
'source_url' => array(
1851+
'description' => __( 'URL to the original attachment file.' ),
1852+
'type' => 'string',
1853+
'format' => 'uri',
1854+
'context' => array( 'edit' ),
1855+
'readonly' => true,
1856+
),
1857+
),
1858+
),
1859+
),
18001860
);
18011861

18021862
$schema['properties']['post'] = array(

‎tests/phpunit/tests/rest-api/rest-attachments-controller.php‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6063,4 +6063,169 @@ public function test_url_arg_rejects_unsafe_urls() {
60636063
$this->assertSame( 400, $result->get_error_data()['status'] );
60646064
}
60656065
}
6066+
6067+
/**
6068+
* Edits an image and returns the ID of the attachment the edit created.
6069+
*
6070+
* @param int $attachment_id Attachment to edit.
6071+
* @return int New attachment ID.
6072+
*/
6073+
private function edit_image_and_get_new_id( $attachment_id ) {
6074+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/edit" );
6075+
$request->set_body_params(
6076+
array(
6077+
'rotation' => 60,
6078+
'src' => wp_get_attachment_image_url( $attachment_id, 'full' ),
6079+
)
6080+
);
6081+
6082+
$response = rest_do_request( $request );
6083+
$this->assertSame( 201, $response->get_status(), 'The image edit should have succeeded.' );
6084+
6085+
$data = $response->get_data();
6086+
6087+
return $data['id'];
6088+
}
6089+
6090+
/**
6091+
* @ticket 65987
6092+
*/
6093+
public function test_get_original_attachment_id_returns_same_id_for_an_upload() {
6094+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6095+
6096+
$this->assertSame( $attachment, wp_get_original_attachment_id( $attachment ) );
6097+
}
6098+
6099+
/**
6100+
* @ticket 65987
6101+
* @requires function imagejpeg
6102+
*/
6103+
public function test_edit_records_the_edited_image_as_the_original() {
6104+
wp_set_current_user( self::$superadmin_id );
6105+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6106+
6107+
$edited = $this->edit_image_and_get_new_id( $attachment );
6108+
6109+
$this->assertSame( $attachment, wp_get_original_attachment_id( $edited ) );
6110+
}
6111+
6112+
/**
6113+
* @ticket 65987
6114+
* @requires function imagejpeg
6115+
*/
6116+
public function test_editing_an_edited_image_keeps_the_first_original() {
6117+
wp_set_current_user( self::$superadmin_id );
6118+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6119+
6120+
$edited = $this->edit_image_and_get_new_id( $attachment );
6121+
$edited_again = $this->edit_image_and_get_new_id( $edited );
6122+
6123+
$this->assertSame(
6124+
$attachment,
6125+
wp_get_original_attachment_id( $edited_again ),
6126+
'An edit of an edit should still point at the image the chain started from.'
6127+
);
6128+
}
6129+
6130+
/**
6131+
* @ticket 65987
6132+
* @requires function imagejpeg
6133+
*/
6134+
public function test_edited_image_response_includes_the_original_attachment() {
6135+
wp_set_current_user( self::$superadmin_id );
6136+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6137+
6138+
$edited = $this->edit_image_and_get_new_id( $attachment );
6139+
6140+
$request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" );
6141+
$request->set_param( 'context', 'edit' );
6142+
$data = rest_do_request( $request )->get_data();
6143+
6144+
$this->assertArrayHasKey( 'original_attachment', $data['media_details'] );
6145+
$this->assertSame(
6146+
$attachment,
6147+
$data['media_details']['original_attachment']['attachment_id']
6148+
);
6149+
$this->assertSame(
6150+
wp_get_attachment_url( $attachment ),
6151+
$data['media_details']['original_attachment']['source_url']
6152+
);
6153+
}
6154+
6155+
/**
6156+
* @ticket 65987
6157+
*/
6158+
public function test_uploaded_image_response_omits_the_original_attachment() {
6159+
wp_set_current_user( self::$superadmin_id );
6160+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6161+
6162+
$request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" );
6163+
$request->set_param( 'context', 'edit' );
6164+
$data = rest_do_request( $request )->get_data();
6165+
6166+
$this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] );
6167+
}
6168+
6169+
/**
6170+
* @ticket 65987
6171+
* @requires function imagejpeg
6172+
*/
6173+
public function test_view_context_omits_the_original_attachment() {
6174+
wp_set_current_user( self::$superadmin_id );
6175+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6176+
6177+
$edited = $this->edit_image_and_get_new_id( $attachment );
6178+
6179+
$request = new WP_REST_Request( 'GET', "/wp/v2/media/{$edited}" );
6180+
$request->set_param( 'context', 'view' );
6181+
$data = rest_do_request( $request )->get_data();
6182+
6183+
$this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] );
6184+
}
6185+
6186+
/**
6187+
* An attachment recorded as its own original is a broken record, not a chain,
6188+
* so nothing should be reported for it.
6189+
*
6190+
* @ticket 65987
6191+
*/
6192+
public function test_attachment_recorded_as_its_own_original_omits_the_field() {
6193+
wp_set_current_user( self::$superadmin_id );
6194+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6195+
6196+
update_post_meta( $attachment, '_wp_attachment_original_id', $attachment );
6197+
6198+
$request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" );
6199+
$request->set_param( 'context', 'edit' );
6200+
$data = rest_do_request( $request )->get_data();
6201+
6202+
$this->assertArrayNotHasKey( 'original_attachment', $data['media_details'] );
6203+
}
6204+
6205+
/**
6206+
* @ticket 65987
6207+
* @requires function imagejpeg
6208+
*/
6209+
public function test_deleting_an_original_clears_it_from_the_images_edited_from_it() {
6210+
wp_set_current_user( self::$superadmin_id );
6211+
6212+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
6213+
$edited = $this->edit_image_and_get_new_id( $attachment );
6214+
6215+
$unrelated = self::factory()->attachment->create_upload_object( self::$test_file );
6216+
$unrelated_edited = $this->edit_image_and_get_new_id( $unrelated );
6217+
6218+
wp_delete_attachment( $attachment, true );
6219+
6220+
$this->assertSame(
6221+
'',
6222+
get_post_meta( $edited, '_wp_attachment_original_id', true ),
6223+
'The record pointing at the deleted attachment should have been cleared.'
6224+
);
6225+
$this->assertSame(
6226+
$unrelated,
6227+
wp_get_original_attachment_id( $unrelated_edited ),
6228+
'An unrelated image should have kept its record.'
6229+
);
6230+
}
60666231
}

0 commit comments

Comments
 (0)