Make WordPress Core

Changeset 63014


Ignore:
Timestamp:
08/04/2026 10:56:49 PM (5 weeks ago)
Author:
adamsilverstein
Message:

Media: Skip server-side image scaling during client-side media processing.

Disable the big_image_size_threshold filter alongside the existing client-side processing filters so the upload is stored untouched. The client's scaled sideload then keeps the plain -scaled name and records the untouched upload as original_image. Uploads that leave generate_sub_sizes enabled are unaffected.

Props khokansardar, ianmjones.
Fixes #65708.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

    r62841 r63014  
    460460                        // This preserves the original orientation value in the metadata.
    461461                        add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
     462                        // Disable server-side "big image" downscaling; the client supplies its
     463                        // own scaled version via the sideload endpoint. Scaling here would
     464                        // create a conflicting "-scaled" file and orphan the full-size upload.
     465                        add_filter( 'big_image_size_threshold', '__return_false', 100 );
    462466                }
    463467
     
    692696                remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
    693697                remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
     698                remove_filter( 'big_image_size_threshold', '__return_false', 100 );
    694699        }
    695700
  • trunk/tests/phpunit/tests/rest-api/rest-attachments-controller.php

    r62841 r63014  
    38773877
    38783878        /**
     3879         * When the client generates sub-sizes (generate_sub_sizes is false), the
     3880         * server must not perform its own "big image" downscaling on upload.
     3881         *
     3882         * Otherwise the server creates a `-scaled` file and records the upload as
     3883         * `original_image`. The client's subsequent scaled sideload then collides
     3884         * with that `-scaled` file and is renamed `-scaled-1`, the thumbnails
     3885         * inherit the numbered name, and the server-generated full-size file is
     3886         * left orphaned on disk.
     3887         *
     3888         * @ticket 65708
     3889         * @requires function imagejpeg
     3890         */
     3891        public function test_create_item_skips_big_image_scaling_when_client_generates_sub_sizes() {
     3892                $this->enable_client_side_media_processing();
     3893
     3894                wp_set_current_user( self::$author_id );
     3895
     3896                // Force the threshold below the image's dimensions so scaling would be
     3897                // triggered were it not suppressed for client-side processing.
     3898                add_filter(
     3899                        'big_image_size_threshold',
     3900                        static function () {
     3901                                return 1000;
     3902                        }
     3903                );
     3904
     3905                // Upload a large image with the client handling sub-size generation.
     3906                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3907                $request->set_header( 'Content-Type', 'image/jpeg' );
     3908                $request->set_header( 'Content-Disposition', 'attachment; filename=33772.jpg' );
     3909                $request->set_param( 'generate_sub_sizes', false );
     3910                $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) );
     3911                $response      = rest_get_server()->dispatch( $request );
     3912                $data          = $response->get_data();
     3913                $attachment_id = $data['id'];
     3914
     3915                $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' );
     3916
     3917                // The uploaded full-size image should be stored untouched: no
     3918                // server-side "-scaled" file and no original_image swap.
     3919                $original_file      = get_attached_file( $attachment_id, true );
     3920                $original_basename  = wp_basename( $original_file );
     3921                $original_name_stem = pathinfo( $original_basename, PATHINFO_FILENAME );
     3922                $this->assertStringNotContainsString( '-scaled', $original_basename, 'The server should not create a -scaled file when the client generates sub-sizes.' );
     3923
     3924                $metadata = wp_get_attachment_metadata( $attachment_id );
     3925                $this->assertArrayNotHasKey( 'original_image', $metadata, 'The server should not record an original_image when it does not scale the upload.' );
     3926
     3927                // The client's scaled sideload should now record the untouched upload as
     3928                // original_image and keep the -scaled name without a numeric suffix.
     3929                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3930                $request->set_header( 'Content-Type', 'image/jpeg' );
     3931                $request->set_header( 'Content-Disposition', "attachment; filename={$original_name_stem}-scaled.jpg" );
     3932                $request->set_param( 'image_size', 'scaled' );
     3933                $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) );
     3934                $response = rest_get_server()->dispatch( $request );
     3935
     3936                $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' );
     3937
     3938                $sub_size = $response->get_data();
     3939                $this->assertSame( $original_basename, $sub_size['original_image'], 'The untouched upload should be recorded as original_image.' );
     3940                $this->assertSame( "{$original_name_stem}-scaled.jpg", wp_basename( $sub_size['file'] ), 'The scaled sideload should keep the -scaled name without a numeric collision suffix.' );
     3941        }
     3942
     3943        /**
     3944         * The complete client-side flow for an image over the "big image" threshold
     3945         * should write only files that the metadata tracks, so that deleting the
     3946         * attachment removes all of them.
     3947         *
     3948         * When the server scales the upload as well, its own full-size file is
     3949         * never referenced by the metadata and survives "Delete Permanently", the
     3950         * client's scaled sideload collides with the server's "-scaled" file and is
     3951         * stored as "-scaled-1", and the sub-sizes inherit the numbered name.
     3952         *
     3953         * @ticket 65708
     3954         * @covers WP_REST_Attachments_Controller::create_item
     3955         * @covers WP_REST_Attachments_Controller::sideload_item
     3956         * @covers WP_REST_Attachments_Controller::finalize_item
     3957         * @requires function imagejpeg
     3958         */
     3959        public function test_client_side_big_image_flow_leaves_no_orphaned_files() {
     3960                $this->enable_client_side_media_processing();
     3961
     3962                wp_set_current_user( self::$author_id );
     3963
     3964                // Force the threshold below the uploaded image's dimensions so scaling
     3965                // would be triggered were it not suppressed for client-side processing.
     3966                add_filter(
     3967                        'big_image_size_threshold',
     3968                        static function () {
     3969                                return 1000;
     3970                        }
     3971                );
     3972
     3973                $upload_dir   = wp_upload_dir();
     3974                $files_before = (array) glob( $upload_dir['path'] . '/*' );
     3975
     3976                // 1. Upload the full-size image; the client owns all the derivatives.
     3977                //    33772.jpg is 1920x1080, so it exceeds the threshold above.
     3978                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3979                $request->set_header( 'Content-Type', 'image/jpeg' );
     3980                $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo.jpg' );
     3981                $request->set_param( 'generate_sub_sizes', false );
     3982                $request->set_body( file_get_contents( DIR_TESTDATA . '/images/33772.jpg' ) );
     3983                $response      = rest_get_server()->dispatch( $request );
     3984                $attachment_id = $response->get_data()['id'];
     3985
     3986                $this->assertSame( 201, $response->get_status(), 'Uploading the image should succeed.' );
     3987
     3988                /*
     3989                 * 2. Sideload a thumbnail, as the client does for each sub-size. The
     3990                 *    client names it after the file it uploaded, so a server-side
     3991                 *    rename of that file is what pushes this into a collision.
     3992                 *    test-image.jpg is 50x50, within the registered thumbnail maximum.
     3993                 */
     3994                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3995                $request->set_header( 'Content-Type', 'image/jpeg' );
     3996                $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-150x150.jpg' );
     3997                $request->set_param( 'image_size', 'thumbnail' );
     3998                $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) );
     3999                $response       = rest_get_server()->dispatch( $request );
     4000                $thumbnail_data = $response->get_data();
     4001
     4002                $this->assertSame( 200, $response->get_status(), 'Sideloading the thumbnail should succeed.' );
     4003                $this->assertSame( 'big-photo-150x150.jpg', wp_basename( $thumbnail_data['file'] ), 'The thumbnail should not inherit a numeric collision suffix.' );
     4004
     4005                // 3. Sideload the scaled full-size image. canola.jpg is 640x480, the
     4006                //    size the client would have downscaled the upload to.
     4007                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     4008                $request->set_header( 'Content-Type', 'image/jpeg' );
     4009                $request->set_header( 'Content-Disposition', 'attachment; filename=big-photo-scaled.jpg' );
     4010                $request->set_param( 'image_size', 'scaled' );
     4011                $request->set_body( file_get_contents( self::$test_file ) );
     4012                $response    = rest_get_server()->dispatch( $request );
     4013                $scaled_data = $response->get_data();
     4014
     4015                $this->assertSame( 200, $response->get_status(), 'Sideloading the scaled image should succeed.' );
     4016
     4017                // 4. Finalize, which writes the collected sub-size metadata in one pass.
     4018                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
     4019                $request->set_param( 'sub_sizes', array( $thumbnail_data, $scaled_data ) );
     4020                $response = rest_get_server()->dispatch( $request );
     4021
     4022                $this->assertSame( 200, $response->get_status(), 'Finalize should succeed.' );
     4023
     4024                $metadata = wp_get_attachment_metadata( $attachment_id );
     4025
     4026                $this->assertSame( 'big-photo.jpg', $metadata['original_image'], 'The untouched upload should be recorded as original_image.' );
     4027                $this->assertSame( 'big-photo-scaled.jpg', wp_basename( $metadata['file'] ), 'The client-supplied scaled image should become the attached file.' );
     4028                $this->assertSame( 'big-photo-150x150.jpg', $metadata['sizes']['thumbnail']['file'], 'The thumbnail should keep its dimension-based name.' );
     4029
     4030                // Every file written for this attachment must be reachable from the
     4031                // metadata, otherwise it is orphaned on disk.
     4032                $written = array_map( 'wp_basename', array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before ) );
     4033                sort( $written );
     4034                $this->assertSame(
     4035                        array( 'big-photo-150x150.jpg', 'big-photo-scaled.jpg', 'big-photo.jpg' ),
     4036                        $written,
     4037                        'The flow should write only the full-size upload, its scaled copy, and the sub-sizes.'
     4038                );
     4039
     4040                // Deleting the attachment should therefore clean all of them up.
     4041                wp_delete_attachment( $attachment_id, true );
     4042
     4043                $remaining = array_diff( (array) glob( $upload_dir['path'] . '/*' ), $files_before );
     4044                $this->assertSame( array(), array_values( $remaining ), 'Deleting the attachment should leave no files behind.' );
     4045        }
     4046
     4047        /**
    38794048         * Tests that sideloading scaled image requires authentication.
    38804049         *
Note: See TracChangeset for help on using the changeset viewer.