Skip to content

Commit cc19ca7

Browse files
REST API: Add finalize endpoint to WP_REST_Attachments_Controller.
Introduce a `POST /wp/v2/media/{id}/finalize` REST API endpoint that re-triggers the `wp_generate_attachment_metadata` filter with context `'update'` after client-side media processing completes. This ensures server-side plugins (watermarking, CDN sync, custom sizes, etc.) can post-process attachments when client-side processing is active. The endpoint reuses `edit_media_item_permissions_check` for authorization and is only registered when `wp_is_client_side_media_processing_enabled()` returns true. See WordPress/gutenberg#74913. See WordPress/gutenberg#74358. Props adamsilverstein, westonruter, mukesh27, divyeshpatel01. Fixes #64804. git-svn-id: https://develop.svn.wordpress.org/trunk@61982 602fd350-edb4-49c9-b593-d223f7449a82
1 parent f29f594 commit cc19ca7

File tree

4 files changed

+181
-0
lines changed

4 files changed

+181
-0
lines changed

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,26 @@ public function register_routes() {
103103
'schema' => array( $this, 'get_public_item_schema' ),
104104
)
105105
);
106+
107+
register_rest_route(
108+
$this->namespace,
109+
'/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
110+
array(
111+
array(
112+
'methods' => WP_REST_Server::CREATABLE,
113+
'callback' => array( $this, 'finalize_item' ),
114+
'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
115+
'args' => array(
116+
'id' => array(
117+
'description' => __( 'Unique identifier for the attachment.' ),
118+
'type' => 'integer',
119+
),
120+
),
121+
),
122+
'allow_batch' => $this->allow_batch,
123+
'schema' => array( $this, 'get_public_item_schema' ),
124+
)
125+
);
106126
}
107127
}
108128

@@ -2191,4 +2211,48 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at
21912211

21922212
return $filename;
21932213
}
2214+
2215+
/**
2216+
* Finalizes an attachment after client-side media processing.
2217+
*
2218+
* Triggers the 'wp_generate_attachment_metadata' filter so that
2219+
* server-side plugins can process the attachment after all client-side
2220+
* operations (upload, thumbnail generation, sideloads) are complete.
2221+
*
2222+
* @since 7.0.0
2223+
*
2224+
* @param WP_REST_Request $request Full details about the request.
2225+
* @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
2226+
*/
2227+
public function finalize_item( WP_REST_Request $request ) {
2228+
$attachment_id = $request['id'];
2229+
2230+
$post = $this->get_post( $attachment_id );
2231+
if ( is_wp_error( $post ) ) {
2232+
return $post;
2233+
}
2234+
2235+
$metadata = wp_get_attachment_metadata( $attachment_id );
2236+
if ( ! is_array( $metadata ) ) {
2237+
$metadata = array();
2238+
}
2239+
2240+
/** This filter is documented in wp-admin/includes/image.php */
2241+
$metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
2242+
2243+
wp_update_attachment_metadata( $attachment_id, $metadata );
2244+
2245+
$response_request = new WP_REST_Request(
2246+
WP_REST_Server::READABLE,
2247+
rest_get_route_for_post( $attachment_id )
2248+
);
2249+
2250+
$response_request['context'] = 'edit';
2251+
2252+
if ( isset( $request['_fields'] ) ) {
2253+
$response_request['_fields'] = $request['_fields'];
2254+
}
2255+
2256+
return $this->prepare_item_for_response( $post, $response_request );
2257+
}
21942258
}

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3447,4 +3447,100 @@ public function test_sideload_scaled_unique_filename_conflict() {
34473447
$basename = wp_basename( $new_file );
34483448
$this->assertMatchesRegularExpression( '/canola-scaled-\d+\.jpg$/', $basename, 'Scaled filename should have numeric suffix when file conflicts with a different attachment.' );
34493449
}
3450+
3451+
/**
3452+
* Tests that the finalize endpoint triggers wp_generate_attachment_metadata.
3453+
*
3454+
* @ticket 62243
3455+
* @covers WP_REST_Attachments_Controller::finalize_item
3456+
* @requires function imagejpeg
3457+
*/
3458+
public function test_finalize_item(): void {
3459+
wp_set_current_user( self::$author_id );
3460+
3461+
// Create an attachment.
3462+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3463+
$request->set_header( 'Content-Type', 'image/jpeg' );
3464+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
3465+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3466+
$response = rest_get_server()->dispatch( $request );
3467+
$attachment_id = $response->get_data()['id'];
3468+
3469+
$this->assertSame( 201, $response->get_status() );
3470+
3471+
// Track whether wp_generate_attachment_metadata filter fires.
3472+
$filter_metadata = null;
3473+
$filter_id = null;
3474+
$filter_context = null;
3475+
add_filter(
3476+
'wp_generate_attachment_metadata',
3477+
function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, &$filter_id, &$filter_context ) {
3478+
$filter_metadata = $metadata;
3479+
$filter_id = $id;
3480+
$filter_context = $context;
3481+
$metadata['foo'] = 'bar';
3482+
return $metadata;
3483+
},
3484+
10,
3485+
3
3486+
);
3487+
3488+
// Call the finalize endpoint.
3489+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
3490+
$response = rest_get_server()->dispatch( $request );
3491+
3492+
$this->assertSame( 200, $response->get_status(), 'Finalize endpoint should return 200.' );
3493+
$this->assertIsArray( $filter_metadata );
3494+
$this->assertStringContainsString( 'canola', $filter_metadata['file'], 'Expected the canola image to have been had its metadata updated.' );
3495+
$this->assertSame( $attachment_id, $filter_id, 'Expected the post ID to be passed to the filter.' );
3496+
$this->assertSame( 'update', $filter_context, 'Filter context should be "update".' );
3497+
$resulting_metadata = wp_get_attachment_metadata( $attachment_id );
3498+
$this->assertIsArray( $resulting_metadata );
3499+
$this->assertArrayHasKey( 'foo', $resulting_metadata, 'Expected new metadata key to have been added.' );
3500+
$this->assertSame( 'bar', $resulting_metadata['foo'], 'Expected filtered metadata to be updated.' );
3501+
}
3502+
3503+
/**
3504+
* Tests that the finalize endpoint requires authentication.
3505+
*
3506+
* @ticket 62243
3507+
* @covers WP_REST_Attachments_Controller::finalize_item
3508+
* @requires function imagejpeg
3509+
*/
3510+
public function test_finalize_item_requires_auth(): void {
3511+
wp_set_current_user( self::$author_id );
3512+
3513+
// Create an attachment.
3514+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
3515+
$request->set_header( 'Content-Type', 'image/jpeg' );
3516+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
3517+
$request->set_body( (string) file_get_contents( self::$test_file ) );
3518+
$response = rest_get_server()->dispatch( $request );
3519+
$attachment_id = $response->get_data()['id'];
3520+
3521+
// Try finalizing without authentication.
3522+
wp_set_current_user( 0 );
3523+
3524+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
3525+
$response = rest_get_server()->dispatch( $request );
3526+
3527+
$this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 );
3528+
}
3529+
3530+
/**
3531+
* Tests that the finalize endpoint returns error for invalid attachment ID.
3532+
*
3533+
* @ticket 62243
3534+
* @covers WP_REST_Attachments_Controller::finalize_item
3535+
*/
3536+
public function test_finalize_item_invalid_id(): void {
3537+
wp_set_current_user( self::$author_id );
3538+
3539+
$invalid_id = PHP_INT_MAX;
3540+
$this->assertNull( get_post( $invalid_id ), 'Expected invalid ID to not exist for an existing post.' );
3541+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/$invalid_id/finalize" );
3542+
$response = rest_get_server()->dispatch( $request );
3543+
3544+
$this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
3545+
}
34503546
}

tests/phpunit/tests/rest-api/rest-schema-setup.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ public function test_expected_routes_in_schema() {
113113
'/wp/v2/media/(?P<id>[\\d]+)/post-process',
114114
'/wp/v2/media/(?P<id>[\\d]+)/edit',
115115
'/wp/v2/media/(?P<id>[\\d]+)/sideload',
116+
'/wp/v2/media/(?P<id>[\\d]+)/finalize',
116117
'/wp/v2/blocks',
117118
'/wp/v2/blocks/(?P<id>[\d]+)',
118119
'/wp/v2/blocks/(?P<id>[\d]+)/autosaves',

tests/qunit/fixtures/wp-api-generated.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3719,6 +3719,26 @@ mockedApiResponse.Schema = {
37193719
}
37203720
]
37213721
},
3722+
"/wp/v2/media/(?P<id>[\\d]+)/finalize": {
3723+
"namespace": "wp/v2",
3724+
"methods": [
3725+
"POST"
3726+
],
3727+
"endpoints": [
3728+
{
3729+
"methods": [
3730+
"POST"
3731+
],
3732+
"args": {
3733+
"id": {
3734+
"description": "Unique identifier for the attachment.",
3735+
"type": "integer",
3736+
"required": false
3737+
}
3738+
}
3739+
}
3740+
]
3741+
},
37223742
"/wp/v2/menu-items": {
37233743
"namespace": "wp/v2",
37243744
"methods": [

0 commit comments

Comments
 (0)