Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ public function register_routes() {
'schema' => array( $this, 'get_public_item_schema' ),
)
);

register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'finalize_item' ),
'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the attachment.' ),
'type' => 'integer',
),
),
),
'allow_batch' => $this->allow_batch,
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
}

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

return $filename;
}

/**
* Finalizes an attachment after client-side media processing.
*
* Triggers the 'wp_generate_attachment_metadata' filter so that
* server-side plugins can process the attachment after all client-side
* operations (upload, thumbnail generation, sideloads) are complete.
*
* @since 7.0.0
*
* @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.
*/
public function finalize_item( WP_REST_Request $request ) {
$attachment_id = $request['id'];

$post = $this->get_post( $attachment_id );
if ( is_wp_error( $post ) ) {
return $post;
}

$metadata = wp_get_attachment_metadata( $attachment_id );
if ( ! is_array( $metadata ) ) {
$metadata = array();
}

/** This filter is documented in wp-admin/includes/image.php */
$metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );

wp_update_attachment_metadata( $attachment_id, $metadata );

$response_request = new WP_REST_Request(
WP_REST_Server::READABLE,
rest_get_route_for_post( $attachment_id )
);

$response_request['context'] = 'edit';

if ( isset( $request['_fields'] ) ) {
$response_request['_fields'] = $request['_fields'];
}

return $this->prepare_item_for_response( $post, $response_request );
}
}
96 changes: 96 additions & 0 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -3447,4 +3447,100 @@ public function test_sideload_scaled_unique_filename_conflict() {
$basename = wp_basename( $new_file );
$this->assertMatchesRegularExpression( '/canola-scaled-\d+\.jpg$/', $basename, 'Scaled filename should have numeric suffix when file conflicts with a different attachment.' );
}

/**
* Tests that the finalize endpoint triggers wp_generate_attachment_metadata.
*
* @ticket 62243
* @covers WP_REST_Attachments_Controller::finalize_item
* @requires function imagejpeg
*/
public function test_finalize_item(): void {
wp_set_current_user( self::$author_id );

// Create an attachment.
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
$request->set_body( (string) file_get_contents( self::$test_file ) );
$response = rest_get_server()->dispatch( $request );
$attachment_id = $response->get_data()['id'];

$this->assertSame( 201, $response->get_status() );

// Track whether wp_generate_attachment_metadata filter fires.
$filter_metadata = null;
$filter_id = null;
$filter_context = null;
add_filter(
'wp_generate_attachment_metadata',
function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, &$filter_id, &$filter_context ) {
$filter_metadata = $metadata;
$filter_id = $id;
$filter_context = $context;
$metadata['foo'] = 'bar';
return $metadata;
},
10,
3
);

// Call the finalize endpoint.
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 200, $response->get_status(), 'Finalize endpoint should return 200.' );
$this->assertIsArray( $filter_metadata );
$this->assertStringContainsString( 'canola', $filter_metadata['file'], 'Expected the canola image to have been had its metadata updated.' );
$this->assertSame( $attachment_id, $filter_id, 'Expected the post ID to be passed to the filter.' );
$this->assertSame( 'update', $filter_context, 'Filter context should be "update".' );
$resulting_metadata = wp_get_attachment_metadata( $attachment_id );
$this->assertIsArray( $resulting_metadata );
$this->assertArrayHasKey( 'foo', $resulting_metadata, 'Expected new metadata key to have been added.' );
$this->assertSame( 'bar', $resulting_metadata['foo'], 'Expected filtered metadata to be updated.' );
}

/**
* Tests that the finalize endpoint requires authentication.
*
* @ticket 62243
* @covers WP_REST_Attachments_Controller::finalize_item
* @requires function imagejpeg
*/
public function test_finalize_item_requires_auth(): void {
wp_set_current_user( self::$author_id );

// Create an attachment.
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
$request->set_body( (string) file_get_contents( self::$test_file ) );
$response = rest_get_server()->dispatch( $request );
$attachment_id = $response->get_data()['id'];

// Try finalizing without authentication.
wp_set_current_user( 0 );

$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
$response = rest_get_server()->dispatch( $request );

$this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 );
}

/**
* Tests that the finalize endpoint returns error for invalid attachment ID.
*
* @ticket 62243
* @covers WP_REST_Attachments_Controller::finalize_item
*/
public function test_finalize_item_invalid_id(): void {
wp_set_current_user( self::$author_id );

$invalid_id = PHP_INT_MAX;
$this->assertNull( get_post( $invalid_id ), 'Expected invalid ID to not exist for an existing post.' );
$request = new WP_REST_Request( 'POST', "/wp/v2/media/$invalid_id/finalize" );
$response = rest_get_server()->dispatch( $request );

$this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
}
}
1 change: 1 addition & 0 deletions tests/phpunit/tests/rest-api/rest-schema-setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public function test_expected_routes_in_schema() {
'/wp/v2/media/(?P<id>[\\d]+)/post-process',
'/wp/v2/media/(?P<id>[\\d]+)/edit',
'/wp/v2/media/(?P<id>[\\d]+)/sideload',
'/wp/v2/media/(?P<id>[\\d]+)/finalize',
'/wp/v2/blocks',
'/wp/v2/blocks/(?P<id>[\d]+)',
'/wp/v2/blocks/(?P<id>[\d]+)/autosaves',
Expand Down
20 changes: 20 additions & 0 deletions tests/qunit/fixtures/wp-api-generated.js
Original file line number Diff line number Diff line change
Expand Up @@ -3719,6 +3719,26 @@ mockedApiResponse.Schema = {
}
]
},
"/wp/v2/media/(?P<id>[\\d]+)/finalize": {
"namespace": "wp/v2",
"methods": [
"POST"
],
"endpoints": [
{
"methods": [
"POST"
],
"args": {
"id": {
"description": "Unique identifier for the attachment.",
"type": "integer",
"required": false
}
}
}
]
},
"/wp/v2/menu-items": {
"namespace": "wp/v2",
"methods": [
Expand Down
Loading