Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Address review: guard the embeddable link and trust the stored meta
rest_prepare_attachment fires twice per attachment (once from the
posts controller, again from the attachments controller wrapping it),
so add_link() appended a duplicate wp:original-attachment link; only
add it when absent. Drop the wp_get_attachment_url() reachability
check: it cost an uncached meta query per derivative, delete_attachment
cleanup keeps the meta accurate, and a consumer fetching a dangling id
simply gets no record.
  • Loading branch information
ramonjd committed Oct 2, 2026
commit 29df3b9ac98e99cd73717d806c3b1b7f8a1b66c8
40 changes: 23 additions & 17 deletions lib/experimental/media/original-attachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,24 +120,30 @@ function gutenberg_add_original_attachment_to_response( $response, $post, $reque
return $response;
}

// Skip when the original is unreachable (deleted, missing file):
// don't advertise a restore target that cannot load.
$source_url = wp_get_attachment_url( $original_id );
if ( ! is_string( $source_url ) || '' === $source_url ) {
return $response;
}

// Trust the stored meta rather than verifying the original here:
// the `delete_attachment` cleanup below keeps it accurate, and a
// consumer fetching a dangling id (e.g. an original sitting in the
// trash) simply gets no record. Verifying would cost an uncached
// meta query per derivative on every list request.
$data['original_attachment'] = $original_id;
$response->set_data( $data );

// Mirror `featured_media`: expose the relationship as an
// embeddable link so clients can hydrate the original attachment
// with `?_embed`.
$response->add_link(
'https://api.w.org/original-attachment',
rest_url( 'wp/v2/media/' . $original_id ),
array( 'embeddable' => true )
);
// with `?_embed`. Core fires `rest_prepare_attachment` twice per
// attachment — once from the posts controller's
// `prepare_item_for_response()` and again from the attachments
// controller wrapping it — and `add_link()` appends
// unconditionally, so guard against adding the link twice.
$rel = 'https://api.w.org/original-attachment';
$links = $response->get_links();
if ( ! isset( $links[ $rel ] ) ) {
$response->add_link(
$rel,
rest_url( 'wp/v2/media/' . $original_id ),
array( 'embeddable' => true )
);
}

return $response;
}
Expand All @@ -148,10 +154,10 @@ function gutenberg_add_original_attachment_to_response( $response, $post, $reque
* original is deleted.
*
* Without this, descendants would carry a dangling pointer at a
* recycled attachment id. The REST filter already skips emitting the
* field when the URL doesn't resolve, but cleaning up the stored meta
* keeps the database accurate and prevents the field from
* resurrecting if the deleted id is later reused.
* recycled attachment id. The REST filter trusts this meta without
* verifying the original still exists, so this cleanup is what keeps
* the exposed field accurate and prevents it from pointing at an
* unrelated attachment if the deleted id is later reused.
*
* Runs on hard delete only: with `MEDIA_TRASH`, trashing fires no
* `delete_attachment`, so descendants keep pointing at a trashed
Expand Down
4 changes: 4 additions & 0 deletions phpunit/experimental/media/original-attachment-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,17 @@ public function test_original_attachment_link_is_embeddable() {

$links = $response->get_links();
$this->assertArrayHasKey( 'https://api.w.org/original-attachment', $links );
// `rest_prepare_attachment` fires twice per attachment; the
// filter must not add the link twice.
$this->assertCount( 1, $links['https://api.w.org/original-attachment'] );

$link = $links['https://api.w.org/original-attachment'][0];
$this->assertStringEndsWith( '/wp/v2/media/' . $original, $link['href'] );
$this->assertTrue( $link['attributes']['embeddable'] );

// The curie-compacted rel hydrates under `_embedded` with `?_embed`.
$embedded = rest_get_server()->response_to_data( $response, true );
$this->assertCount( 1, $embedded['_embedded']['wp:original-attachment'] );
$this->assertSame(
$original,
$embedded['_embedded']['wp:original-attachment'][0]['id']
Expand Down