From 77cc0710858f52a8a13ff90e8da5547600002421 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Mar 2026 13:49:52 +0700 Subject: [PATCH 1/7] REST API: Add finalize endpoint to WP_REST_Attachments_Controller Add a `POST /wp/v2/media/{id}/finalize` endpoint that triggers the `wp_generate_attachment_metadata` filter after client-side media processing completes. This ensures server-side plugins (e.g., for watermarking, CDN sync, custom sizes) can post-process attachments when client-side processing is active. The endpoint: - Reuses `edit_media_item_permissions_check` for authorization - Re-applies `wp_generate_attachment_metadata` with context 'update' - Updates attachment metadata and returns the updated attachment - Is only registered when client-side media processing is enabled Props adamsilverstein. See #62243. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-rest-attachments-controller.php | 79 ++++++++++++++++++ .../rest-api/rest-attachments-controller.php | 83 +++++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 1 + tests/qunit/fixtures/wp-api-generated.js | 20 +++++ 4 files changed, 183 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 848b4e56d75a1..97f77c4404edf 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -103,6 +103,26 @@ public function register_routes() { 'schema' => array( $this, 'get_public_item_schema' ), ) ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/finalize', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'finalize_item' ), + 'permission_callback' => array( $this, 'finalize_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' ), + ) + ); } } @@ -2176,4 +2196,63 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at return $filename; } + + /** + * Checks if a given request has access to finalize an attachment. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access, WP_Error object otherwise. + */ + public function finalize_item_permissions_check( $request ) { + return $this->edit_media_item_permissions_check( $request ); + } + + /** + * 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( get_post( $attachment_id ), $response_request ); + } + } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 93cd4211c93ba..70b039856b700 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3343,4 +3343,87 @@ 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 + * @requires function imagejpeg + */ + public function test_finalize_item() { + 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( 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_called = false; + $filter_context = null; + add_filter( + 'wp_generate_attachment_metadata', + function ( $metadata, $id, $context ) use ( &$filter_called, &$filter_context ) { + $filter_called = true; + $filter_context = $context; + 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->assertTrue( $filter_called, 'wp_generate_attachment_metadata filter should have been called.' ); + $this->assertSame( 'update', $filter_context, 'Filter context should be "update".' ); + } + + /** + * Tests that the finalize endpoint requires authentication. + * + * @ticket 62243 + * @requires function imagejpeg + */ + public function test_finalize_item_requires_auth() { + 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( 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 + */ + public function test_finalize_item_invalid_id() { + wp_set_current_user( self::$author_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media/999999/finalize' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 0e0e00b934359..ca8080fafa71e 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -110,6 +110,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)/post-process', '/wp/v2/media/(?P[\\d]+)/edit', '/wp/v2/media/(?P[\\d]+)/sideload', + '/wp/v2/media/(?P[\\d]+)/finalize', '/wp/v2/blocks', '/wp/v2/blocks/(?P[\d]+)', '/wp/v2/blocks/(?P[\d]+)/autosaves', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 487fb2067a978..e2db70db8e7e3 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3718,6 +3718,26 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/media/(?P[\\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": [ From 672fd49e456dc7cea777f460c0e5258957b15ad8 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 10 Mar 2026 11:59:19 -0700 Subject: [PATCH 2/7] Apply suggestions from @westonruter Co-authored-by: Weston Ruter --- .../endpoints/class-wp-rest-attachments-controller.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 97f77c4404edf..0ef95dd130feb 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -2205,7 +2205,7 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access, WP_Error object otherwise. */ - public function finalize_item_permissions_check( $request ) { + public function finalize_item_permissions_check( WP_REST_Request $request ) { return $this->edit_media_item_permissions_check( $request ); } @@ -2252,7 +2252,7 @@ public function finalize_item( WP_REST_Request $request ) { $response_request['_fields'] = $request['_fields']; } - return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request ); + return $this->prepare_item_for_response( $post, $response_request ); } } From 3a82b96cf85c31e0e6d79e199acaf09e3bea63b6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Mar 2026 12:02:31 -0700 Subject: [PATCH 3/7] Remove extraneous blank lines per review Address remaining code style feedback from @westonruter. --- .../endpoints/class-wp-rest-attachments-controller.php | 3 --- tests/phpunit/tests/rest-api/rest-attachments-controller.php | 1 - 2 files changed, 4 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 0ef95dd130feb..86735ee6a8acb 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -2225,13 +2225,11 @@ 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(); } @@ -2254,5 +2252,4 @@ public function finalize_item( WP_REST_Request $request ) { return $this->prepare_item_for_response( $post, $response_request ); } - } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 70b039856b700..546c33f5e60cd 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3425,5 +3425,4 @@ public function test_finalize_item_invalid_id() { $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); } - } From 4de3c1dc5fbe9703733ba236303cc2d79ec72636 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Mar 2026 12:05:20 -0700 Subject: [PATCH 4/7] Fix PHPCS alignment warning in tests Align equals signs for adjacent variable assignments per WordPress coding standards. --- tests/phpunit/tests/rest-api/rest-attachments-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 546c33f5e60cd..368546f3b74f1 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3364,7 +3364,7 @@ public function test_finalize_item() { $this->assertSame( 201, $response->get_status() ); // Track whether wp_generate_attachment_metadata filter fires. - $filter_called = false; + $filter_called = false; $filter_context = null; add_filter( 'wp_generate_attachment_metadata', From a1364698de3949c6e35d1186bfda637c69312bbf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 10 Mar 2026 17:50:51 -0700 Subject: [PATCH 5/7] Expand assertions and harden types in tests --- .../rest-api/rest-attachments-controller.php | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 368546f3b74f1..a3593e55d8310 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3350,27 +3350,29 @@ public function test_sideload_scaled_unique_filename_conflict() { * @ticket 62243 * @requires function imagejpeg */ - public function test_finalize_item() { + 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( file_get_contents( self::$test_file ) ); + $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_called = false; - $filter_context = null; + $filter_metadata = null; + $filter_id = null; + $filter_context = null; add_filter( 'wp_generate_attachment_metadata', - function ( $metadata, $id, $context ) use ( &$filter_called, &$filter_context ) { - $filter_called = true; - $filter_context = $context; + function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, &$filter_id, &$filter_context ) { + $filter_metadata = $metadata; + $filter_id = $id; + $filter_context = $context; return $metadata; }, 10, @@ -3382,7 +3384,9 @@ function ( $metadata, $id, $context ) use ( &$filter_called, &$filter_context ) $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Finalize endpoint should return 200.' ); - $this->assertTrue( $filter_called, 'wp_generate_attachment_metadata filter should have been called.' ); + $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".' ); } @@ -3392,14 +3396,14 @@ function ( $metadata, $id, $context ) use ( &$filter_called, &$filter_context ) * @ticket 62243 * @requires function imagejpeg */ - public function test_finalize_item_requires_auth() { + 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( file_get_contents( self::$test_file ) ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); $response = rest_get_server()->dispatch( $request ); $attachment_id = $response->get_data()['id']; @@ -3417,10 +3421,12 @@ public function test_finalize_item_requires_auth() { * * @ticket 62243 */ - public function test_finalize_item_invalid_id() { + public function test_finalize_item_invalid_id(): void { wp_set_current_user( self::$author_id ); - $request = new WP_REST_Request( 'POST', '/wp/v2/media/999999/finalize' ); + $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 ); From ba6d05d764de714766642ddf2e040076bf0c8690 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 10 Mar 2026 17:57:15 -0700 Subject: [PATCH 6/7] Add assertions that wp_update_attachment_metadata() was actually called --- .../tests/rest-api/rest-attachments-controller.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index a3593e55d8310..0783a60f8d8ce 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3348,6 +3348,7 @@ public function test_sideload_scaled_unique_filename_conflict() { * 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 { @@ -3373,6 +3374,7 @@ function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, $filter_metadata = $metadata; $filter_id = $id; $filter_context = $context; + $metadata['foo'] = 'bar'; return $metadata; }, 10, @@ -3388,12 +3390,17 @@ function ( array $metadata, int $id, string $context ) use ( &$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 { @@ -3420,6 +3427,7 @@ public function test_finalize_item_requires_auth(): void { * 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 ); From 8af2fa67d4061fdddabde20f2b63569616b1ceca Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Mar 2026 22:49:13 -0700 Subject: [PATCH 7/7] Use edit_media_item_permissions_check directly Remove redundant finalize_item_permissions_check wrapper and use edit_media_item_permissions_check directly for the finalize route, consistent with the sideload route pattern. --- .../class-wp-rest-attachments-controller.php | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 50b8e49a67ff0..e61a198d19bb8 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -111,7 +111,7 @@ public function register_routes() { array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'finalize_item' ), - 'permission_callback' => array( $this, 'finalize_item_permissions_check' ), + 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), @@ -2206,18 +2206,6 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at return $filename; } - /** - * Checks if a given request has access to finalize an attachment. - * - * @since 7.0.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access, WP_Error object otherwise. - */ - public function finalize_item_permissions_check( WP_REST_Request $request ) { - return $this->edit_media_item_permissions_check( $request ); - } - /** * Finalizes an attachment after client-side media processing. *