Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ public function register_routes() {
$valid_image_sizes[] = 'original';
// Used for PDF thumbnails.
$valid_image_sizes[] = 'full';
// Client-side big image threshold: sideload the scaled version.
$valid_image_sizes[] = 'scaled';

register_rest_route(
$this->namespace,
Expand Down Expand Up @@ -1966,6 +1968,114 @@ public function sideload_item_permissions_check( $request ) {
return $this->edit_media_item_permissions_check( $request );
}

/**
* Validates that uploaded image dimensions are appropriate for the specified image size.
*
* @since 7.0.0
*
* @param int $width Uploaded image width.
* @param int $height Uploaded image height.
* @param string $image_size The target image size name.
* @param int $attachment_id The attachment ID.
* @return true|WP_Error True if valid, WP_Error if invalid.
*/
private function validate_image_dimensions( int $width, int $height, string $image_size, int $attachment_id ) {
// 'original' size: should match original attachment dimensions.
if ( 'original' === $image_size ) {
$metadata = wp_get_attachment_metadata( $attachment_id, true );
if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
$expected_width = (int) $metadata['width'];
$expected_height = (int) $metadata['height'];

if ( $width !== $expected_width || $height !== $expected_height ) {
return new WP_Error(
'rest_upload_dimension_mismatch',
sprintf(
/* translators: 1: Expected width, 2: expected height, 3: actual width, 4: actual height. */
__( 'Uploaded image dimensions (%3$dx%4$d) do not match original image dimensions (%1$dx%2$d).' ),
$expected_width,
$expected_height,
$width,
$height
),
Comment on lines +1994 to +2000
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any deeper reasoning why you chose to switch order in the printf template over the order of how they are in the sprintf()here?

In my eyes it would be simpler to have them (by default) in the same order as they are displayed, as this would make things easier to read and reduce cognitive load.

array( 'status' => 400 )
);
}
}
return true;
}

// 'full' size (PDF thumbnails) and 'scaled': dimensions must be positive.
if ( 'full' === $image_size || 'scaled' === $image_size ) {
if ( $width <= 0 || $height <= 0 ) {
return new WP_Error(
'rest_upload_invalid_dimensions',
__( 'Uploaded image must have positive dimensions.' ),
array( 'status' => 400 )
);
}
return true;
}
Comment on lines +2008 to +2018
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any valid case where negative dimensions are actually allowed?

I see that empty could be allowable for SVGs, but I can't see any case where a negative integer is a valid dimension.


// Regular image sizes: validate against registered size constraints.
$registered_sizes = wp_get_registered_image_subsizes();

if ( ! isset( $registered_sizes[ $image_size ] ) ) {
return new WP_Error(
'rest_upload_unknown_size',
__( 'Unknown image size.' ),
array( 'status' => 400 )
);
}

$size_data = $registered_sizes[ $image_size ];
$max_width = (int) $size_data['width'];
$max_height = (int) $size_data['height'];

// Dimensions must be positive.
if ( $width <= 0 || $height <= 0 ) {
return new WP_Error(
'rest_upload_invalid_dimensions',
__( 'Uploaded image must have positive dimensions.' ),
array( 'status' => 400 )
);
Comment on lines +2035 to +2041
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above, you're checking this here, if it is a valid case for having 2 checks, maybe this could be refactored into a function that performs the check, so you don't repeat yourself?

}

// Validate dimensions don't exceed the registered size maximums.
// Allow 1px tolerance for rounding differences.
$tolerance = 1;

if ( $max_width > 0 && $width > $max_width + $tolerance ) {
return new WP_Error(
'rest_upload_dimension_mismatch',
sprintf(
/* translators: 1: Image size name, 2: maximum width, 3: actual width. */
__( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).' ),
$image_size,
$max_width,
$width
),
array( 'status' => 400 )
);
}

if ( $max_height > 0 && $height > $max_height + $tolerance ) {
return new WP_Error(
'rest_upload_dimension_mismatch',
sprintf(
/* translators: 1: Image size name, 2: maximum height, 3: actual height. */
__( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).' ),
$image_size,
$max_height,
$height
),
array( 'status' => 400 )
);
}

return true;
}

/**
* Side-loads a media file without creating a new attachment.
*
Expand Down Expand Up @@ -2045,6 +2155,18 @@ public function sideload_item( WP_REST_Request $request ) {

$image_size = $request['image_size'];

$size = wp_getimagesize( $path );

// Validate dimensions match expected size.
if ( $size ) {
$validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
if ( is_wp_error( $validation ) ) {
// Clean up the uploaded file.
wp_delete_file( $path );
return $validation;
}
}

$metadata = wp_get_attachment_metadata( $attachment_id, true );

if ( ! $metadata ) {
Expand All @@ -2053,11 +2175,30 @@ public function sideload_item( WP_REST_Request $request ) {

if ( 'original' === $image_size ) {
$metadata['original_image'] = wp_basename( $path );
} elseif ( 'scaled' === $image_size ) {
// The current attached file is the original; record it as original_image.
$current_file = get_attached_file( $attachment_id, true );

if ( ! $current_file ) {
return new WP_Error(
'rest_sideload_no_attached_file',
__( 'Unable to retrieve the attached file for this attachment.' ),
array( 'status' => 400 )
);
}

$metadata['original_image'] = wp_basename( $current_file );

// Update the attached file to point to the scaled version.
update_attached_file( $attachment_id, $path );

$metadata['width'] = $size ? $size[0] : 0;
$metadata['height'] = $size ? $size[1] : 0;
$metadata['filesize'] = wp_filesize( $path );
$metadata['file'] = _wp_relative_upload_path( $path );
} else {
$metadata['sizes'] = $metadata['sizes'] ?? array();

$size = wp_getimagesize( $path );

$metadata['sizes'][ $image_size ] = array(
'width' => $size ? $size[0] : 0,
'height' => $size ? $size[1] : 0,
Expand Down Expand Up @@ -2123,7 +2264,7 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at
}

$matches = array();
if ( preg_match( '/(.*)(-\d+x\d+)-' . $number . '$/', $name, $matches ) ) {
if ( preg_match( '/(.*)(-\d+x\d+|-scaled)-' . $number . '$/', $name, $matches ) ) {
$filename_without_suffix = $matches[1] . $matches[2] . ".$ext";
if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) {
return $filename_without_suffix;
Expand Down
192 changes: 192 additions & 0 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -3154,4 +3154,196 @@ static function ( $data ) use ( &$captured_data ) {
// Verify that the data is an array (not an object).
$this->assertIsArray( $captured_data, 'Data passed to wp_insert_attachment should be an array' );
}

/**
* Tests sideloading a scaled image for an existing attachment.
*
* @ticket 63
* @requires function imagejpeg
*/
public function test_sideload_scaled_image() {
wp_set_current_user( self::$author_id );

// First, 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 );
$data = $response->get_data();
$attachment_id = $data['id'];

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

$original_file = get_attached_file( $attachment_id, true );

// Sideload a "scaled" version of the image.
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
$request->set_param( 'image_size', 'scaled' );
$request->set_body( file_get_contents( self::$test_file ) );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' );

$metadata = wp_get_attachment_metadata( $attachment_id );

// The original file should now be recorded as original_image.
$this->assertArrayHasKey( 'original_image', $metadata, 'Metadata should contain original_image.' );
$this->assertSame( wp_basename( $original_file ), $metadata['original_image'], 'original_image should be the basename of the original attached file.' );

// The attached file should now point to the scaled version.
$new_file = get_attached_file( $attachment_id, true );
$this->assertStringContainsString( 'scaled', wp_basename( $new_file ), 'Attached file should now be the scaled version.' );

// Metadata should have width, height, filesize, and file updated.
$this->assertArrayHasKey( 'width', $metadata, 'Metadata should contain width.' );
$this->assertArrayHasKey( 'height', $metadata, 'Metadata should contain height.' );
$this->assertArrayHasKey( 'filesize', $metadata, 'Metadata should contain filesize.' );
$this->assertArrayHasKey( 'file', $metadata, 'Metadata should contain file.' );
$this->assertGreaterThan( 0, $metadata['width'], 'Width should be positive.' );
$this->assertGreaterThan( 0, $metadata['height'], 'Height should be positive.' );
$this->assertGreaterThan( 0, $metadata['filesize'], 'Filesize should be positive.' );
}

/**
* Tests that sideloading scaled image requires authentication.
*
* @ticket 63
* @requires function imagejpeg
*/
public function test_sideload_scaled_image_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 sideloading without authentication.
wp_set_current_user( 0 );

$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
$request->set_param( 'image_size', 'scaled' );
$request->set_body( file_get_contents( self::$test_file ) );
$response = rest_get_server()->dispatch( $request );

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

/**
* Tests that the sideload endpoint includes 'scaled' in the image_size enum.
*
* @ticket 63
*/
public function test_sideload_route_includes_scaled_enum() {
$server = rest_get_server();
$routes = $server->get_routes();

$this->assertArrayHasKey( '/wp/v2/media/(?P<id>[\d]+)/sideload', $routes, 'Sideload route should exist.' );

$route = $routes['/wp/v2/media/(?P<id>[\d]+)/sideload'];
$endpoint = $route[0];
$args = $endpoint['args'];

$this->assertArrayHasKey( 'image_size', $args, 'Route should have image_size arg.' );
$this->assertContains( 'scaled', $args['image_size']['enum'], 'image_size enum should include scaled.' );
}

/**
* Tests the filter_wp_unique_filename method handles the -scaled suffix.
*
* @ticket 63
* @requires function imagejpeg
*/
public function test_sideload_scaled_unique_filename() {
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'];

// Sideload with the -scaled suffix.
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
$request->set_param( 'image_size', 'scaled' );
$request->set_body( file_get_contents( self::$test_file ) );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' );

// The filename should retain the -scaled suffix without numeric disambiguation.
$new_file = get_attached_file( $attachment_id, true );
$basename = wp_basename( $new_file );
$this->assertMatchesRegularExpression( '/canola-scaled\.jpg$/', $basename, 'Scaled filename should not have numeric suffix appended.' );
}

/**
* Tests that sideloading an oversized image for a registered size is rejected.
*
* @ticket 63
* @requires function imagejpeg
*/
public function test_sideload_item_rejects_oversized_dimensions() {
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'];

// canola.jpg is 640x480, which exceeds the default thumbnail size (150x150).
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-150x150.jpg' );
$request->set_param( 'image_size', 'thumbnail' );
$request->set_body( file_get_contents( self::$test_file ) );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 400, $response->get_status(), 'Oversized image should be rejected.' );
$this->assertSame( 'rest_upload_dimension_mismatch', $response->get_data()['code'], 'Error code should be rest_upload_dimension_mismatch.' );
}

/**
* Tests that sideloading a correctly sized image for a registered size succeeds.
*
* @ticket 63
* @requires function imagejpeg
*/
public function test_sideload_item_accepts_valid_dimensions() {
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'];

// test-image.jpg is 50x50, which fits within the default thumbnail size (150x150).
$test_image = DIR_TESTDATA . '/images/test-image.jpg';
$request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
$request->set_header( 'Content-Type', 'image/jpeg' );
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.jpg' );
$request->set_param( 'image_size', 'thumbnail' );
$request->set_body( file_get_contents( $test_image ) );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 200, $response->get_status(), 'Valid-sized image should be accepted.' );
}
}
3 changes: 2 additions & 1 deletion tests/qunit/fixtures/wp-api-generated.js
Original file line number Diff line number Diff line change
Expand Up @@ -3703,7 +3703,8 @@ mockedApiResponse.Schema = {
"1536x1536",
"2048x2048",
"original",
"full"
"full",
"scaled"
],
"required": true
},
Expand Down
Loading