Make WordPress Core

Changeset 62428


Ignore:
Timestamp:
05/28/2026 03:27:18 PM (4 months ago)
Author:
adamsilverstein
Message:

Media: Re-introduce client-side media processing feature.

Reverts the removal in [62081] now that WordPress 7.1 has forked. Restores all PHP functions, REST API endpoints, cross-origin isolation infrastructure, VIPS script module handling, build configuration, and associated tests.

Follow-up to [62081].

Props adamsilverstein, jorbin, westonruter.
Fixes #64919.
See #64906.

Location:
trunk
Files:
2 added
14 edited

Legend:

Unmodified
Added
Removed
  • trunk/Gruntfile.js

    r62411 r62428  
    703703                                                '**/*',
    704704                                                '!**/*.map',
    705                                                 '!vips/**',
     705                                                // Skip non-minified VIPS files — they are ~16MB of inlined WASM
     706                                                // with no debugging value over the minified versions.
     707                                                '!vips/!(*.min).js',
    706708                                        ],
    707709                                        dest: WORKING_DIR + 'wp-includes/js/dist/script-modules/',
  • trunk/src/wp-includes/assets/script-loader-packages.php

    r62380 r62428  
    844844                ),
    845845                'module_dependencies' => array(
    846                        
     846                        array(
     847                                'id' => '@wordpress/vips/worker',
     848                                'import' => 'dynamic'
     849                        )
    847850                ),
    848851                'version' => 'd359c2cccf866d7082d2'
  • trunk/src/wp-includes/assets/script-modules-packages.php

    r62380 r62428  
    285285                'version' => 'c5843b6c5e84b352f43b'
    286286        ),
     287        'vips/loader.js' => array(
     288                'dependencies' => array(
     289                       
     290                ),
     291                'module_dependencies' => array(
     292                        array(
     293                                'id' => '@wordpress/vips/worker',
     294                                'import' => 'dynamic'
     295                        )
     296                ),
     297                'version' => '07c9acb45d3e5d81829a'
     298        ),
     299        'vips/worker.js' => array(
     300                'dependencies' => array(
     301                       
     302                ),
     303                'version' => 'aff5e5c5b28ae6b73aaa'
     304        ),
    287305        'workflow/index.js' => array(
    288306                'dependencies' => array(
  • trunk/src/wp-includes/default-filters.php

    r62417 r62428  
    685685add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' );
    686686
     687// Client-side media processing.
     688add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' );
     689// Cross-origin isolation for client-side media processing.
     690add_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' );
     691add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
     692add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
     693add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );
    687694// Nav menu.
    688695add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 );
  • trunk/src/wp-includes/media-template.php

    r62081 r62428  
    157157        $class = 'media-modal wp-core-ui';
    158158
     159        $is_cross_origin_isolation_enabled = wp_is_client_side_media_processing_enabled();
     160
     161        if ( $is_cross_origin_isolation_enabled ) {
     162                ob_start();
     163        }
     164
    159165        $alt_text_description = sprintf(
    160166                /* translators: 1: Link to tutorial, 2: Additional link attributes, 3: Accessibility text. */
     
    15831589         */
    15841590        do_action( 'print_media_templates' );
     1591
     1592        if ( $is_cross_origin_isolation_enabled ) {
     1593                $html = (string) ob_get_clean();
     1594
     1595                /*
     1596                 * The media templates are inside <script type="text/html"> tags,
     1597                 * whose content is treated as raw text by the HTML Tag Processor.
     1598                 * Extract each script block's content, process it separately,
     1599                 * then reassemble the full output.
     1600                 */
     1601                $script_processor = new WP_HTML_Tag_Processor( $html );
     1602                while ( $script_processor->next_tag( 'SCRIPT' ) ) {
     1603                        if ( 'text/html' !== $script_processor->get_attribute( 'type' ) ) {
     1604                                continue;
     1605                        }
     1606                        /*
     1607                         * Unlike wp_add_crossorigin_attributes(), this does not check whether
     1608                         * URLs are actually cross-origin. Media templates use Underscore.js
     1609                         * template expressions (e.g. {{ data.url }}) as placeholder URLs,
     1610                         * so actual URLs are not available at parse time.
     1611                         * The crossorigin attribute is added unconditionally to all relevant
     1612                         * media tags to ensure cross-origin isolation works regardless of
     1613                         * the final URL value at render time.
     1614                         */
     1615                        $template_processor = new WP_HTML_Tag_Processor( $script_processor->get_modifiable_text() );
     1616                        while ( $template_processor->next_tag() ) {
     1617                                if (
     1618                                        in_array( $template_processor->get_tag(), array( 'AUDIO', 'IMG', 'VIDEO' ), true )
     1619                                        && ! is_string( $template_processor->get_attribute( 'crossorigin' ) )
     1620                                ) {
     1621                                        $template_processor->set_attribute( 'crossorigin', 'anonymous' );
     1622                                }
     1623                        }
     1624                        $script_processor->set_modifiable_text( $template_processor->get_updated_html() );
     1625                }
     1626
     1627                echo $script_processor->get_updated_html();
     1628        }
    15851629}
  • trunk/src/wp-includes/media.php

    r62178 r62428  
    64486448}
    64496449
     6450/**
     6451 * Checks whether client-side media processing is enabled.
     6452 *
     6453 * Client-side media processing uses the browser's capabilities to handle
     6454 * tasks like image resizing and compression before uploading to the server.
     6455 *
     6456 * @since 7.1.0
     6457 *
     6458 * @return bool Whether client-side media processing is enabled.
     6459 */
     6460function wp_is_client_side_media_processing_enabled(): bool {
     6461        // This is due to SharedArrayBuffer requiring a secure context.
     6462        $host    = strtolower( (string) strtok( $_SERVER['HTTP_HOST'] ?? '', ':' ) );
     6463        $enabled = ( is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ) );
     6464
     6465        /**
     6466         * Filters whether client-side media processing is enabled.
     6467         *
     6468         * @since 7.1.0
     6469         *
     6470         * @param bool $enabled Whether client-side media processing is enabled. Default true if the page is served in a secure context.
     6471         */
     6472        return (bool) apply_filters( 'wp_client_side_media_processing_enabled', $enabled );
     6473}
     6474
     6475/**
     6476 * Sets a global JS variable to indicate that client-side media processing is enabled.
     6477 *
     6478 * @since 7.1.0
     6479 */
     6480function wp_set_client_side_media_processing_flag(): void {
     6481        if ( ! wp_is_client_side_media_processing_enabled() ) {
     6482                return;
     6483        }
     6484
     6485        wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' );
     6486
     6487        $chromium_version = wp_get_chromium_major_version();
     6488
     6489        if ( null !== $chromium_version && $chromium_version >= 137 ) {
     6490                wp_add_inline_script( 'wp-block-editor', 'window.__documentIsolationPolicy = true;', 'before' );
     6491        }
     6492
     6493        /*
     6494         * Register the @wordpress/vips/worker script module as a dynamic dependency
     6495         * of the wp-upload-media classic script. This ensures it is included in the
     6496         * import map so that the dynamic import() in upload-media.js can resolve it.
     6497         */
     6498        wp_scripts()->add_data(
     6499                'wp-upload-media',
     6500                'module_dependencies',
     6501                array( '@wordpress/vips/worker' )
     6502        );
     6503}
     6504
     6505/**
     6506 * Returns the major Chrome/Chromium version from the current request's User-Agent.
     6507 *
     6508 * Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave).
     6509 *
     6510 * @since 7.1.0
     6511 *
     6512 * @return int|null The major Chrome version, or null if not a Chromium browser.
     6513 */
     6514function wp_get_chromium_major_version(): ?int {
     6515        if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
     6516                return null;
     6517        }
     6518        if ( preg_match( '#Chrome/(\d+)#', $_SERVER['HTTP_USER_AGENT'], $matches ) ) {
     6519                return (int) $matches[1];
     6520        }
     6521        return null;
     6522}
     6523
     6524/**
     6525 * Enables cross-origin isolation in the block editor.
     6526 *
     6527 * Required for enabling SharedArrayBuffer for WebAssembly-based
     6528 * media processing in the editor. Uses Document-Isolation-Policy
     6529 * on supported browsers (Chromium 137+).
     6530 *
     6531 * Skips setup when a third-party page builder overrides the block
     6532 * editor via a custom `action` query parameter, as DIP would block
     6533 * same-origin iframe access that these editors rely on.
     6534 *
     6535 * @since 7.1.0
     6536 */
     6537function wp_set_up_cross_origin_isolation(): void {
     6538        if ( ! wp_is_client_side_media_processing_enabled() ) {
     6539                return;
     6540        }
     6541
     6542        $screen = get_current_screen();
     6543
     6544        if ( ! $screen ) {
     6545                return;
     6546        }
     6547
     6548        if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
     6549                return;
     6550        }
     6551
     6552        /*
     6553         * Skip when a third-party page builder overrides the block editor.
     6554         * DIP isolates the document into its own agent cluster,
     6555         * which blocks same-origin iframe access that these editors rely on.
     6556         */
     6557        if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
     6558                return;
     6559        }
     6560
     6561        // Cross-origin isolation is not needed if users can't upload files anyway.
     6562        if ( ! current_user_can( 'upload_files' ) ) {
     6563                return;
     6564        }
     6565
     6566        wp_start_cross_origin_isolation_output_buffer();
     6567}
     6568
     6569/**
     6570 * Sends the Document-Isolation-Policy header for cross-origin isolation.
     6571 *
     6572 * Uses an output buffer to add crossorigin="anonymous" where needed.
     6573 *
     6574 * @since 7.1.0
     6575 */
     6576function wp_start_cross_origin_isolation_output_buffer(): void {
     6577        $chromium_version = wp_get_chromium_major_version();
     6578
     6579        if ( null === $chromium_version || $chromium_version < 137 ) {
     6580                return;
     6581        }
     6582
     6583        ob_start(
     6584                static function ( string $output ): string {
     6585                        header( 'Document-Isolation-Policy: isolate-and-credentialless' );
     6586
     6587                        return wp_add_crossorigin_attributes( $output );
     6588                }
     6589        );
     6590}
     6591
     6592/**
     6593 * Adds crossorigin="anonymous" to relevant tags in the given HTML string.
     6594 *
     6595 * @since 7.1.0
     6596 *
     6597 * @param string $html HTML input.
     6598 * @return string Modified HTML.
     6599 */
     6600function wp_add_crossorigin_attributes( string $html ): string {
     6601        $site_url = site_url();
     6602
     6603        $processor = new WP_HTML_Tag_Processor( $html );
     6604
     6605        // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
     6606        $cross_origin_tag_attributes = array(
     6607                'AUDIO'  => array( 'src' ),
     6608                'LINK'   => array( 'href' ),
     6609                'SCRIPT' => array( 'src' ),
     6610                'VIDEO'  => array( 'src', 'poster' ),
     6611                'SOURCE' => array( 'src' ),
     6612        );
     6613
     6614        while ( $processor->next_tag() ) {
     6615                $tag = $processor->get_tag();
     6616
     6617                if ( ! isset( $cross_origin_tag_attributes[ $tag ] ) ) {
     6618                        continue;
     6619                }
     6620                $crossorigin = $processor->get_attribute( 'crossorigin' );
     6621                if ( null !== $crossorigin ) {
     6622                        continue;
     6623                }
     6624
     6625                if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) {
     6626                        $processor->set_bookmark( 'audio-video-parent' );
     6627                }
     6628
     6629                $processor->set_bookmark( 'resume' );
     6630
     6631                $sought = false;
     6632
     6633                $is_cross_origin = false;
     6634
     6635                foreach ( $cross_origin_tag_attributes[ $tag ] as $attr ) {
     6636                        $url = $processor->get_attribute( $attr );
     6637                        if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) ) {
     6638                                $is_cross_origin = true;
     6639                        }
     6640
     6641                        if ( $is_cross_origin ) {
     6642                                break;
     6643                        }
     6644                }
     6645
     6646                if ( $is_cross_origin ) {
     6647                        if ( 'SOURCE' === $tag ) {
     6648                                $sought = $processor->seek( 'audio-video-parent' );
     6649
     6650                                if ( $sought ) {
     6651                                        $processor->set_attribute( 'crossorigin', 'anonymous' );
     6652                                }
     6653                        } else {
     6654                                $processor->set_attribute( 'crossorigin', 'anonymous' );
     6655                        }
     6656
     6657                        if ( $sought ) {
     6658                                $processor->seek( 'resume' );
     6659                                $processor->release_bookmark( 'audio-video-parent' );
     6660                        }
     6661                }
     6662        }
     6663
     6664        return $processor->get_updated_html();
     6665}
     6666
  • trunk/src/wp-includes/rest-api/class-wp-rest-server.php

    r62081 r62428  
    13691369                );
    13701370
     1371                // Add media processing settings for users who can upload files.
     1372                if ( wp_is_client_side_media_processing_enabled() && current_user_can( 'upload_files' ) ) {
     1373                        // Image sizes keyed by name for client-side media processing.
     1374                        $available['image_sizes'] = array();
     1375                        foreach ( wp_get_registered_image_subsizes() as $name => $size ) {
     1376                                $available['image_sizes'][ $name ] = $size;
     1377                        }
     1378
     1379                        /** This filter is documented in wp-admin/includes/image.php */
     1380                        $available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 );
     1381
     1382                        // Image output formats.
     1383                        $input_formats  = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' );
     1384                        $output_formats = array();
     1385                        foreach ( $input_formats as $mime_type ) {
     1386                                /** This filter is documented in wp-includes/media.php */
     1387                                $output_formats = apply_filters( 'image_editor_output_format', $output_formats, '', $mime_type );
     1388                        }
     1389                        $available['image_output_formats'] = (object) $output_formats;
     1390
     1391                        /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
     1392                        $available['jpeg_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' );
     1393                        /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
     1394                        $available['png_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/png' );
     1395                        /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
     1396                        $available['gif_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' );
     1397                }
     1398
    13711399                $response = new WP_REST_Response( $available );
    13721400
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

    r62081 r62428  
    6464                        )
    6565                );
     66
     67                if ( wp_is_client_side_media_processing_enabled() ) {
     68                        $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() );
     69                        // Special case to set 'original_image' in attachment metadata.
     70                        $valid_image_sizes[] = 'original';
     71                        // Used for PDF thumbnails.
     72                        $valid_image_sizes[] = 'full';
     73                        // Client-side big image threshold: sideload the scaled version.
     74                        $valid_image_sizes[] = 'scaled';
     75
     76                        register_rest_route(
     77                                $this->namespace,
     78                                '/' . $this->rest_base . '/(?P<id>[\d]+)/sideload',
     79                                array(
     80                                        array(
     81                                                'methods'             => WP_REST_Server::CREATABLE,
     82                                                'callback'            => array( $this, 'sideload_item' ),
     83                                                'permission_callback' => array( $this, 'sideload_item_permissions_check' ),
     84                                                'args'                => array(
     85                                                        'id'             => array(
     86                                                                'description' => __( 'Unique identifier for the attachment.' ),
     87                                                                'type'        => 'integer',
     88                                                        ),
     89                                                        'image_size'     => array(
     90                                                                'description' => __( 'Image size.' ),
     91                                                                'type'        => 'string',
     92                                                                'enum'        => $valid_image_sizes,
     93                                                                'required'    => true,
     94                                                        ),
     95                                                        'convert_format' => array(
     96                                                                'type'        => 'boolean',
     97                                                                'default'     => true,
     98                                                                'description' => __( 'Whether to convert image formats.' ),
     99                                                        ),
     100                                                ),
     101                                        ),
     102                                        'allow_batch' => $this->allow_batch,
     103                                        'schema'      => array( $this, 'get_public_item_schema' ),
     104                                )
     105                        );
     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                        );
     126                }
     127        }
     128
     129        /**
     130         * Retrieves the query params for the attachments collection.
     131         *
     132         * @since 7.1.0
     133         *
     134         * @param string $method Optional. HTTP method of the request.
     135         *                       The arguments for `CREATABLE` requests are
     136         *                       checked for required values and may fall-back to a given default.
     137         *                       Default WP_REST_Server::CREATABLE.
     138         * @return array<string, array<string, mixed>> Endpoint arguments.
     139         */
     140        public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
     141                $args = parent::get_endpoint_args_for_item_schema( $method );
     142
     143                if ( WP_REST_Server::CREATABLE === $method && wp_is_client_side_media_processing_enabled() ) {
     144                        $args['generate_sub_sizes'] = array(
     145                                'type'        => 'boolean',
     146                                'default'     => true,
     147                                'description' => __( 'Whether to generate image sub sizes.' ),
     148                        );
     149                        $args['convert_format']     = array(
     150                                'type'        => 'boolean',
     151                                'default'     => true,
     152                                'description' => __( 'Whether to convert image formats.' ),
     153                        );
     154                }
     155
     156                return $args;
    66157        }
    67158
     
    162253                $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null );
    163254
     255                // When the client handles image processing (generate_sub_sizes is false),
     256                // skip the server-side image editor support check.
     257                if ( false === $request['generate_sub_sizes'] ) {
     258                        $prevent_unsupported_uploads = false;
     259                }
     260
    164261                // If the upload is an image, check if the server can handle the mime type.
    165262                if (
     
    193290         *
    194291         * @since 4.7.0
     292         * @since 7.1.0 Added `generate_sub_sizes` and `convert_format` parameters.
    195293         *
    196294         * @param WP_REST_Request $request Full details about the request.
     
    206304                }
    207305
     306                // Handle generate_sub_sizes parameter.
     307                if ( false === $request['generate_sub_sizes'] ) {
     308                        add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
     309                        add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
     310                        // Disable server-side EXIF rotation so the client can handle it.
     311                        // This preserves the original orientation value in the metadata.
     312                        add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
     313                }
     314
     315                // Handle convert_format parameter.
     316                if ( false === $request['convert_format'] ) {
     317                        add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
     318                }
     319
    208320                $insert = $this->insert_attachment( $request );
    209321
    210322                if ( is_wp_error( $insert ) ) {
     323                        $this->remove_client_side_media_processing_filters();
    211324                        return $insert;
    212325                }
     
    226339
    227340                        if ( is_wp_error( $thumbnail_update ) ) {
     341                                $this->remove_client_side_media_processing_filters();
    228342                                return $thumbnail_update;
    229343                        }
     
    234348
    235349                        if ( is_wp_error( $meta_update ) ) {
     350                                $this->remove_client_side_media_processing_filters();
    236351                                return $meta_update;
    237352                        }
     
    242357
    243358                if ( is_wp_error( $fields_update ) ) {
     359                        $this->remove_client_side_media_processing_filters();
    244360                        return $fields_update;
    245361                }
     
    248364
    249365                if ( is_wp_error( $terms_update ) ) {
     366                        $this->remove_client_side_media_processing_filters();
    250367                        return $terms_update;
    251368                }
     
    284401                wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) );
    285402
     403                $this->remove_client_side_media_processing_filters();
     404
    286405                $response = $this->prepare_item_for_response( $attachment, $request );
    287406                $response = rest_ensure_response( $response );
     
    290409
    291410                return $response;
     411        }
     412
     413        /**
     414         * Removes filters added for client-side media processing.
     415         *
     416         * @since 7.1.0
     417         */
     418        private function remove_client_side_media_processing_filters(): void {
     419                remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
     420                remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
     421                remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
     422                remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
    292423        }
    293424
     
    18571988                return null;
    18581989        }
     1990
     1991        /**
     1992         * Checks if a given request has access to sideload a file.
     1993         *
     1994         * Sideloading a file for an existing attachment
     1995         * requires both update and create permissions.
     1996         *
     1997         * @since 7.1.0
     1998         *
     1999         * @param WP_REST_Request $request Full details about the request.
     2000         * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
     2001         */
     2002        public function sideload_item_permissions_check( $request ) {
     2003                return $this->edit_media_item_permissions_check( $request );
     2004        }
     2005
     2006        /**
     2007         * Side-loads a media file without creating a new attachment.
     2008         *
     2009         * @since 7.1.0
     2010         *
     2011         * @param WP_REST_Request $request Full details about the request.
     2012         * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
     2013         */
     2014        public function sideload_item( WP_REST_Request $request ) {
     2015                $attachment_id = (int) $request['id'];
     2016
     2017                $post = $this->get_post( $attachment_id );
     2018
     2019                if ( is_wp_error( $post ) ) {
     2020                        return $post;
     2021                }
     2022
     2023                if (
     2024                        ! wp_attachment_is_image( $post ) &&
     2025                        ! wp_attachment_is( 'pdf', $post )
     2026                ) {
     2027                        return new WP_Error(
     2028                                'rest_post_invalid_id',
     2029                                __( 'Invalid post ID. Only images and PDFs can be sideloaded.' ),
     2030                                array( 'status' => 400 )
     2031                        );
     2032                }
     2033
     2034                if ( false === $request['convert_format'] ) {
     2035                        // Prevent image conversion as that is done client-side.
     2036                        add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
     2037                }
     2038
     2039                // Get the file via $_FILES or raw data.
     2040                $files   = $request->get_file_params();
     2041                $headers = $request->get_headers();
     2042
     2043                /*
     2044                 * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
     2045                 * See /wp-includes/functions.php.
     2046                 * With the following filter we can work around this safeguard.
     2047                 */
     2048                $attachment_filename = get_attached_file( $attachment_id, true );
     2049                $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null;
     2050
     2051                $filter_filename = static function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) {
     2052                        return self::filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename );
     2053                };
     2054
     2055                add_filter( 'wp_unique_filename', $filter_filename, 10, 6 );
     2056
     2057                $parent_post = get_post_parent( $attachment_id );
     2058
     2059                $time = null;
     2060
     2061                // Matches logic in media_handle_upload().
     2062                // The post date doesn't usually matter for pages, so don't backdate this upload.
     2063                if ( $parent_post && 'page' !== $parent_post->post_type && ! str_starts_with( $parent_post->post_date, '0000-00-00' ) ) {
     2064                        $time = $parent_post->post_date;
     2065                }
     2066
     2067                if ( ! empty( $files ) ) {
     2068                        $file = $this->upload_from_file( $files, $headers, $time );
     2069                } else {
     2070                        $file = $this->upload_from_data( $request->get_body(), $headers, $time );
     2071                }
     2072
     2073                remove_filter( 'wp_unique_filename', $filter_filename );
     2074                remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
     2075
     2076                if ( is_wp_error( $file ) ) {
     2077                        return $file;
     2078                }
     2079
     2080                $type = $file['type'];
     2081                $path = $file['file'];
     2082
     2083                $image_size = $request['image_size'];
     2084
     2085                $metadata = wp_get_attachment_metadata( $attachment_id, true );
     2086
     2087                if ( ! $metadata ) {
     2088                        $metadata = array();
     2089                }
     2090
     2091                if ( 'original' === $image_size ) {
     2092                        $metadata['original_image'] = wp_basename( $path );
     2093                } elseif ( 'scaled' === $image_size ) {
     2094                        // The current attached file is the original; record it as original_image.
     2095                        $current_file = get_attached_file( $attachment_id, true );
     2096
     2097                        if ( ! $current_file ) {
     2098                                return new WP_Error(
     2099                                        'rest_sideload_no_attached_file',
     2100                                        __( 'Unable to retrieve the attached file for this attachment.' ),
     2101                                        array( 'status' => 404 )
     2102                                );
     2103                        }
     2104
     2105                        $metadata['original_image'] = wp_basename( $current_file );
     2106
     2107                        // Validate the scaled image before updating the attached file.
     2108                        $size     = wp_getimagesize( $path );
     2109                        $filesize = wp_filesize( $path );
     2110
     2111                        if ( ! $size || ! $filesize ) {
     2112                                return new WP_Error(
     2113                                        'rest_sideload_invalid_image',
     2114                                        __( 'Unable to read the scaled image file.' ),
     2115                                        array( 'status' => 500 )
     2116                                );
     2117                        }
     2118
     2119                        // Update the attached file to point to the scaled version.
     2120                        if (
     2121                                get_attached_file( $attachment_id, true ) !== $path &&
     2122                                ! update_attached_file( $attachment_id, $path )
     2123                        ) {
     2124                                return new WP_Error(
     2125                                        'rest_sideload_update_attached_file_failed',
     2126                                        __( 'Unable to update the attached file for this attachment.' ),
     2127                                        array( 'status' => 500 )
     2128                                );
     2129                        }
     2130
     2131                        $metadata['width']    = $size[0];
     2132                        $metadata['height']   = $size[1];
     2133                        $metadata['filesize'] = $filesize;
     2134                        $metadata['file']     = _wp_relative_upload_path( $path );
     2135                } else {
     2136                        $metadata['sizes'] = $metadata['sizes'] ?? array();
     2137
     2138                        $size = wp_getimagesize( $path );
     2139
     2140                        $metadata['sizes'][ $image_size ] = array(
     2141                                'width'     => $size ? $size[0] : 0,
     2142                                'height'    => $size ? $size[1] : 0,
     2143                                'file'      => wp_basename( $path ),
     2144                                'mime-type' => $type,
     2145                                'filesize'  => wp_filesize( $path ),
     2146                        );
     2147                }
     2148
     2149                wp_update_attachment_metadata( $attachment_id, $metadata );
     2150
     2151                $response_request = new WP_REST_Request(
     2152                        WP_REST_Server::READABLE,
     2153                        rest_get_route_for_post( $attachment_id )
     2154                );
     2155
     2156                $response_request['context'] = 'edit';
     2157
     2158                if ( isset( $request['_fields'] ) ) {
     2159                        $response_request['_fields'] = $request['_fields'];
     2160                }
     2161
     2162                $response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
     2163
     2164                $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
     2165
     2166                return $response;
     2167        }
     2168
     2169        /**
     2170         * Filters wp_unique_filename during sideloads.
     2171         *
     2172         * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts.
     2173         * Adding this closure to the filter helps work around this safeguard.
     2174         *
     2175         * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg,
     2176         * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg
     2177         * However, here it is desired not to add the suffix in order to maintain the same
     2178         * naming convention as if the file was uploaded regularly.
     2179         *
     2180         * @since 7.1.0
     2181         *
     2182         * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582
     2183         *
     2184         * @param string      $filename            Unique file name.
     2185         * @param string      $dir                 Directory path.
     2186         * @param int|string  $number              The highest number that was used to make the file name unique
     2187         *                                         or an empty string if unused.
     2188         * @param string|null $attachment_filename Original attachment file name.
     2189         * @return string Filtered file name.
     2190         */
     2191        private static function filter_wp_unique_filename( $filename, $dir, $number, $attachment_filename ) {
     2192                if ( ! is_int( $number ) || ! $attachment_filename ) {
     2193                        return $filename;
     2194                }
     2195
     2196                $ext       = pathinfo( $filename, PATHINFO_EXTENSION );
     2197                $name      = pathinfo( $filename, PATHINFO_FILENAME );
     2198                $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME );
     2199
     2200                if ( ! $ext || ! $name ) {
     2201                        return $filename;
     2202                }
     2203
     2204                $matches = array();
     2205                if ( preg_match( '/(.*)-(\d+x\d+|scaled)-' . $number . '$/', $name, $matches ) ) {
     2206                        $filename_without_suffix = $matches[1] . '-' . $matches[2] . ".$ext";
     2207                        if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) {
     2208                                return $filename_without_suffix;
     2209                        }
     2210                }
     2211
     2212                return $filename;
     2213        }
     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.1.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 = (int) $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        }
    18592258}
  • trunk/src/wp-includes/script-modules.php

    r62368 r62428  
    212212                }
    213213
    214                 if ( '' !== $suffix ) {
     214                // VIPS files are always minified — the non-minified versions are not
     215                // shipped because they are ~10MB of inlined WASM with no debugging value.
     216                if ( str_starts_with( $file_name, 'vips/' ) ) {
     217                        $file_name = str_replace( '.js', '.min.js', $file_name );
     218                } elseif ( '' !== $suffix ) {
    215219                        $file_name = str_replace( '.js', $suffix . '.js', $file_name );
    216220                }
  • trunk/tests/phpunit/tests/rest-api/rest-attachments-controller.php

    r62081 r62428  
    193193
    194194                parent::tear_down();
     195        }
     196
     197        /**
     198         * Enables client-side media processing and reinitializes the REST server
     199         * so that the sideload and finalize routes are registered.
     200         */
     201        private function enable_client_side_media_processing(): void {
     202                add_filter( 'wp_client_side_media_processing_enabled', '__return_true' );
     203
     204                global $wp_rest_server;
     205                $wp_rest_server = new Spy_REST_Server();
     206                do_action( 'rest_api_init', $wp_rest_server );
    195207        }
    196208
     
    29312943
    29322944        /**
     2945         * Test that unsupported image type check is skipped when not generating sub-sizes.
     2946         *
     2947         * When the client handles image processing (generate_sub_sizes is false),
     2948         * the server should not check image editor support.
     2949         *
     2950         * Tests the permissions check directly with file params set, since the core
     2951         * check uses get_file_params() which is only populated for multipart uploads.
     2952         *
     2953         * @ticket 64836
     2954         */
     2955        public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() {
     2956                wp_set_current_user( self::$author_id );
     2957
     2958                add_filter( 'wp_image_editors', '__return_empty_array' );
     2959
     2960                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     2961                $request->set_file_params(
     2962                        array(
     2963                                'file' => array(
     2964                                        'name'     => 'avif-lossy.avif',
     2965                                        'type'     => 'image/avif',
     2966                                        'tmp_name' => self::$test_avif_file,
     2967                                        'error'    => 0,
     2968                                        'size'     => filesize( self::$test_avif_file ),
     2969                                ),
     2970                        )
     2971                );
     2972                $request->set_param( 'generate_sub_sizes', false );
     2973
     2974                $controller = new WP_REST_Attachments_Controller( 'attachment' );
     2975                $result     = $controller->create_item_permissions_check( $request );
     2976
     2977                // Should pass because generate_sub_sizes is false (client handles processing).
     2978                $this->assertTrue( $result );
     2979        }
     2980
     2981        /**
    29332982         * Test that unsupported image type check is enforced when generating sub-sizes.
    29342983         *
     
    31923241                $this->assertIsArray( $captured_data, 'Data passed to wp_insert_attachment should be an array' );
    31933242        }
     3243
     3244        /**
     3245         * Tests sideloading a scaled image for an existing attachment.
     3246         *
     3247         * @ticket 64737
     3248         * @requires function imagejpeg
     3249         */
     3250        public function test_sideload_scaled_image() {
     3251                $this->enable_client_side_media_processing();
     3252
     3253                wp_set_current_user( self::$author_id );
     3254
     3255                // First, create an attachment.
     3256                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3257                $request->set_header( 'Content-Type', 'image/jpeg' );
     3258                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3259                $request->set_body( file_get_contents( self::$test_file ) );
     3260                $response      = rest_get_server()->dispatch( $request );
     3261                $data          = $response->get_data();
     3262                $attachment_id = $data['id'];
     3263
     3264                $this->assertSame( 201, $response->get_status() );
     3265
     3266                $original_file = get_attached_file( $attachment_id, true );
     3267
     3268                // Sideload a "scaled" version of the image.
     3269                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3270                $request->set_header( 'Content-Type', 'image/jpeg' );
     3271                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
     3272                $request->set_param( 'image_size', 'scaled' );
     3273                $request->set_body( file_get_contents( self::$test_file ) );
     3274                $response = rest_get_server()->dispatch( $request );
     3275
     3276                $this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' );
     3277
     3278                $metadata = wp_get_attachment_metadata( $attachment_id );
     3279
     3280                // The original file should now be recorded as original_image.
     3281                $this->assertArrayHasKey( 'original_image', $metadata, 'Metadata should contain original_image.' );
     3282                $this->assertSame( wp_basename( $original_file ), $metadata['original_image'], 'original_image should be the basename of the original attached file.' );
     3283
     3284                // The attached file should now point to the scaled version.
     3285                $new_file = get_attached_file( $attachment_id, true );
     3286                $this->assertStringContainsString( 'scaled', wp_basename( $new_file ), 'Attached file should now be the scaled version.' );
     3287
     3288                // Metadata should have width, height, filesize, and file updated.
     3289                $this->assertArrayHasKey( 'width', $metadata, 'Metadata should contain width.' );
     3290                $this->assertArrayHasKey( 'height', $metadata, 'Metadata should contain height.' );
     3291                $this->assertArrayHasKey( 'filesize', $metadata, 'Metadata should contain filesize.' );
     3292                $this->assertArrayHasKey( 'file', $metadata, 'Metadata should contain file.' );
     3293                $this->assertStringContainsString( 'scaled', $metadata['file'], 'Metadata file should reference the scaled version.' );
     3294                $this->assertGreaterThan( 0, $metadata['width'], 'Width should be positive.' );
     3295                $this->assertGreaterThan( 0, $metadata['height'], 'Height should be positive.' );
     3296                $this->assertGreaterThan( 0, $metadata['filesize'], 'Filesize should be positive.' );
     3297        }
     3298
     3299        /**
     3300         * Tests that sideloading scaled image requires authentication.
     3301         *
     3302         * @ticket 64737
     3303         * @requires function imagejpeg
     3304         */
     3305        public function test_sideload_scaled_image_requires_auth() {
     3306                $this->enable_client_side_media_processing();
     3307
     3308                wp_set_current_user( self::$author_id );
     3309
     3310                // Create an attachment.
     3311                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3312                $request->set_header( 'Content-Type', 'image/jpeg' );
     3313                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3314                $request->set_body( file_get_contents( self::$test_file ) );
     3315                $response      = rest_get_server()->dispatch( $request );
     3316                $attachment_id = $response->get_data()['id'];
     3317
     3318                // Try sideloading without authentication.
     3319                wp_set_current_user( 0 );
     3320
     3321                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3322                $request->set_header( 'Content-Type', 'image/jpeg' );
     3323                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
     3324                $request->set_param( 'image_size', 'scaled' );
     3325                $request->set_body( file_get_contents( self::$test_file ) );
     3326                $response = rest_get_server()->dispatch( $request );
     3327
     3328                $this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 );
     3329        }
     3330
     3331        /**
     3332         * Tests that the sideload endpoint includes 'scaled' in the image_size enum.
     3333         *
     3334         * @ticket 64737
     3335         */
     3336        public function test_sideload_route_includes_scaled_enum() {
     3337                $this->enable_client_side_media_processing();
     3338
     3339                $server = rest_get_server();
     3340                $routes = $server->get_routes();
     3341
     3342                $endpoint = '/wp/v2/media/(?P<id>[\d]+)/sideload';
     3343                $this->assertArrayHasKey( $endpoint, $routes, 'Sideload route should exist.' );
     3344
     3345                $route    = $routes[ $endpoint ];
     3346                $endpoint = $route[0];
     3347                $args     = $endpoint['args'];
     3348
     3349                $param_name = 'image_size';
     3350                $this->assertArrayHasKey( $param_name, $args, 'Route should have image_size arg.' );
     3351                $this->assertContains( 'scaled', $args[ $param_name ]['enum'], 'image_size enum should include scaled.' );
     3352        }
     3353
     3354        /**
     3355         * Tests the filter_wp_unique_filename method handles the -scaled suffix.
     3356         *
     3357         * @ticket 64737
     3358         * @requires function imagejpeg
     3359         */
     3360        public function test_sideload_scaled_unique_filename() {
     3361                $this->enable_client_side_media_processing();
     3362
     3363                wp_set_current_user( self::$author_id );
     3364
     3365                // Create an attachment.
     3366                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3367                $request->set_header( 'Content-Type', 'image/jpeg' );
     3368                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3369                $request->set_body( file_get_contents( self::$test_file ) );
     3370                $response      = rest_get_server()->dispatch( $request );
     3371                $attachment_id = $response->get_data()['id'];
     3372
     3373                // Sideload with the -scaled suffix.
     3374                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3375                $request->set_header( 'Content-Type', 'image/jpeg' );
     3376                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
     3377                $request->set_param( 'image_size', 'scaled' );
     3378                $request->set_body( file_get_contents( self::$test_file ) );
     3379                $response = rest_get_server()->dispatch( $request );
     3380
     3381                $this->assertSame( 200, $response->get_status(), 'Sideloading scaled image should succeed.' );
     3382
     3383                // The filename should retain the -scaled suffix without numeric disambiguation.
     3384                $new_file = get_attached_file( $attachment_id, true );
     3385                $basename = wp_basename( $new_file );
     3386                $this->assertMatchesRegularExpression( '/canola-scaled\.jpg$/', $basename, 'Scaled filename should not have numeric suffix appended.' );
     3387        }
     3388
     3389        /**
     3390         * Tests that sideloading a scaled image for a different attachment retains the numeric suffix
     3391         * when a file with the same name already exists on disk.
     3392         *
     3393         * @ticket 64737
     3394         * @requires function imagejpeg
     3395         */
     3396        public function test_sideload_scaled_unique_filename_conflict() {
     3397                $this->enable_client_side_media_processing();
     3398
     3399                wp_set_current_user( self::$author_id );
     3400
     3401                // Create the first attachment.
     3402                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3403                $request->set_header( 'Content-Type', 'image/jpeg' );
     3404                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3405                $request->set_body( file_get_contents( self::$test_file ) );
     3406                $response        = rest_get_server()->dispatch( $request );
     3407                $attachment_id_a = $response->get_data()['id'];
     3408
     3409                // Sideload a scaled image for attachment A, creating canola-scaled.jpg on disk.
     3410                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id_a}/sideload" );
     3411                $request->set_header( 'Content-Type', 'image/jpeg' );
     3412                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
     3413                $request->set_param( 'image_size', 'scaled' );
     3414                $request->set_body( file_get_contents( self::$test_file ) );
     3415                $response = rest_get_server()->dispatch( $request );
     3416
     3417                $this->assertSame( 200, $response->get_status(), 'First sideload should succeed.' );
     3418
     3419                // Create a second, different attachment.
     3420                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3421                $request->set_header( 'Content-Type', 'image/jpeg' );
     3422                $request->set_header( 'Content-Disposition', 'attachment; filename=other.jpg' );
     3423                $request->set_body( file_get_contents( self::$test_file ) );
     3424                $response        = rest_get_server()->dispatch( $request );
     3425                $attachment_id_b = $response->get_data()['id'];
     3426
     3427                // Sideload scaled for attachment B using the same filename that already exists on disk.
     3428                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id_b}/sideload" );
     3429                $request->set_header( 'Content-Type', 'image/jpeg' );
     3430                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-scaled.jpg' );
     3431                $request->set_param( 'image_size', 'scaled' );
     3432                $request->set_body( file_get_contents( self::$test_file ) );
     3433                $response = rest_get_server()->dispatch( $request );
     3434
     3435                $this->assertSame( 200, $response->get_status(), 'Second sideload should succeed.' );
     3436
     3437                // The filename should have a numeric suffix since the base name does not match this attachment.
     3438                $new_file = get_attached_file( $attachment_id_b, true );
     3439                $basename = wp_basename( $new_file );
     3440                $this->assertMatchesRegularExpression( '/canola-scaled-\d+\.jpg$/', $basename, 'Scaled filename should have numeric suffix when file conflicts with a different attachment.' );
     3441        }
     3442
     3443        /**
     3444         * Tests that the finalize endpoint triggers wp_generate_attachment_metadata.
     3445         *
     3446         * @ticket 62243
     3447         * @covers WP_REST_Attachments_Controller::finalize_item
     3448         * @requires function imagejpeg
     3449         */
     3450        public function test_finalize_item(): void {
     3451                $this->enable_client_side_media_processing();
     3452
     3453                wp_set_current_user( self::$author_id );
     3454
     3455                // Create an attachment.
     3456                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3457                $request->set_header( 'Content-Type', 'image/jpeg' );
     3458                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3459                $request->set_body( (string) file_get_contents( self::$test_file ) );
     3460                $response      = rest_get_server()->dispatch( $request );
     3461                $attachment_id = $response->get_data()['id'];
     3462
     3463                $this->assertSame( 201, $response->get_status() );
     3464
     3465                // Track whether wp_generate_attachment_metadata filter fires.
     3466                $filter_metadata = null;
     3467                $filter_id       = null;
     3468                $filter_context  = null;
     3469                add_filter(
     3470                        'wp_generate_attachment_metadata',
     3471                        function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, &$filter_id, &$filter_context ) {
     3472                                $filter_metadata = $metadata;
     3473                                $filter_id       = $id;
     3474                                $filter_context  = $context;
     3475                                $metadata['foo'] = 'bar';
     3476                                return $metadata;
     3477                        },
     3478                        10,
     3479                        3
     3480                );
     3481
     3482                // Call the finalize endpoint.
     3483                $request  = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
     3484                $response = rest_get_server()->dispatch( $request );
     3485
     3486                $this->assertSame( 200, $response->get_status(), 'Finalize endpoint should return 200.' );
     3487                $this->assertIsArray( $filter_metadata );
     3488                $this->assertStringContainsString( 'canola', $filter_metadata['file'], 'Expected the canola image to have been had its metadata updated.' );
     3489                $this->assertSame( $attachment_id, $filter_id, 'Expected the post ID to be passed to the filter.' );
     3490                $this->assertSame( 'update', $filter_context, 'Filter context should be "update".' );
     3491                $resulting_metadata = wp_get_attachment_metadata( $attachment_id );
     3492                $this->assertIsArray( $resulting_metadata );
     3493                $this->assertArrayHasKey( 'foo', $resulting_metadata, 'Expected new metadata key to have been added.' );
     3494                $this->assertSame( 'bar', $resulting_metadata['foo'], 'Expected filtered metadata to be updated.' );
     3495        }
     3496
     3497        /**
     3498         * Tests that the finalize endpoint requires authentication.
     3499         *
     3500         * @ticket 62243
     3501         * @covers WP_REST_Attachments_Controller::finalize_item
     3502         * @requires function imagejpeg
     3503         */
     3504        public function test_finalize_item_requires_auth(): void {
     3505                $this->enable_client_side_media_processing();
     3506
     3507                wp_set_current_user( self::$author_id );
     3508
     3509                // Create an attachment.
     3510                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3511                $request->set_header( 'Content-Type', 'image/jpeg' );
     3512                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3513                $request->set_body( (string) file_get_contents( self::$test_file ) );
     3514                $response      = rest_get_server()->dispatch( $request );
     3515                $attachment_id = $response->get_data()['id'];
     3516
     3517                // Try finalizing without authentication.
     3518                wp_set_current_user( 0 );
     3519
     3520                $request  = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" );
     3521                $response = rest_get_server()->dispatch( $request );
     3522
     3523                $this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 );
     3524        }
     3525
     3526        /**
     3527         * Tests that the finalize endpoint returns error for invalid attachment ID.
     3528         *
     3529         * @ticket 62243
     3530         * @covers WP_REST_Attachments_Controller::finalize_item
     3531         */
     3532        public function test_finalize_item_invalid_id(): void {
     3533                $this->enable_client_side_media_processing();
     3534
     3535                wp_set_current_user( self::$author_id );
     3536
     3537                $invalid_id = PHP_INT_MAX;
     3538                $this->assertNull( get_post( $invalid_id ), 'Expected invalid ID to not exist for an existing post.' );
     3539                $request  = new WP_REST_Request( 'POST', "/wp/v2/media/$invalid_id/finalize" );
     3540                $response = rest_get_server()->dispatch( $request );
     3541
     3542                $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 );
     3543        }
    31943544}
  • trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php

    r62081 r62428  
    1616        public function set_up() {
    1717                parent::set_up();
     18
     19                // Ensure client-side media processing is enabled so the sideload route is registered.
     20                add_filter( 'wp_client_side_media_processing_enabled', '__return_true' );
    1821
    1922                /** @var WP_REST_Server $wp_rest_server */
     
    110113                        '/wp/v2/media/(?P<id>[\\d]+)/post-process',
    111114                        '/wp/v2/media/(?P<id>[\\d]+)/edit',
     115                        '/wp/v2/media/(?P<id>[\\d]+)/sideload',
     116                        '/wp/v2/media/(?P<id>[\\d]+)/finalize',
    112117                        '/wp/v2/blocks',
    113118                        '/wp/v2/blocks/(?P<id>[\d]+)',
  • trunk/tests/phpunit/tests/script-modules/wpScriptModules.php

    r62368 r62428  
    19051905
    19061906        /**
    1907          * Tests that VIPS script modules are not registered in Core.
    1908          *
    1909          * The wasm-vips library is plugin-only and should not be included
    1910          * in WordPress Core builds due to its large size (~16MB per file).
    1911          *
    1912          * @ticket 64906
     1907         * Tests that VIPS script modules always use minified file paths.
     1908         *
     1909         * Non-minified VIPS files are not shipped because they are ~10MB of
     1910         * inlined WASM with no debugging value, so the registration should
     1911         * always point to the .min.js variants.
     1912         *
     1913         * @ticket 64734
    19131914         *
    19141915         * @covers ::wp_default_script_modules
    19151916         */
    1916         public function test_vips_script_modules_not_registered_in_core() {
     1917        public function test_vips_script_modules_always_use_minified_paths() {
    19171918                wp_default_script_modules();
    19181919                wp_enqueue_script_module( '@wordpress/vips/loader' );
     
    19201921                $actual = get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) );
    19211922
    1922                 $this->assertStringNotContainsString( 'vips', $actual );
     1923                $this->assertStringContainsString( 'vips/loader.min.js', $actual );
     1924                $this->assertStringNotContainsString( 'vips/loader.js"', $actual );
    19231925        }
    19241926
  • trunk/tests/qunit/fixtures/wp-api-generated.js

    r62420 r62428  
    31483148                            "description": "The ID for the associated post of the attachment.",
    31493149                            "type": "integer",
     3150                            "required": false
     3151                        },
     3152                        "generate_sub_sizes": {
     3153                            "type": "boolean",
     3154                            "default": true,
     3155                            "description": "Whether to generate image sub sizes.",
     3156                            "required": false
     3157                        },
     3158                        "convert_format": {
     3159                            "type": "boolean",
     3160                            "default": true,
     3161                            "description": "Whether to convert image formats.",
    31503162                            "required": false
    31513163                        }
     
    36653677            ]
    36663678        },
     3679        "/wp/v2/media/(?P<id>[\\d]+)/sideload": {
     3680            "namespace": "wp/v2",
     3681            "methods": [
     3682                "POST"
     3683            ],
     3684            "endpoints": [
     3685                {
     3686                    "methods": [
     3687                        "POST"
     3688                    ],
     3689                    "args": {
     3690                        "id": {
     3691                            "description": "Unique identifier for the attachment.",
     3692                            "type": "integer",
     3693                            "required": false
     3694                        },
     3695                        "image_size": {
     3696                            "description": "Image size.",
     3697                            "type": "string",
     3698                            "enum": [
     3699                                "thumbnail",
     3700                                "medium",
     3701                                "medium_large",
     3702                                "large",
     3703                                "1536x1536",
     3704                                "2048x2048",
     3705                                "original",
     3706                                "full",
     3707                                "scaled"
     3708                            ],
     3709                            "required": true
     3710                        },
     3711                        "convert_format": {
     3712                            "type": "boolean",
     3713                            "default": true,
     3714                            "description": "Whether to convert image formats.",
     3715                            "required": false
     3716                        }
     3717                    }
     3718                }
     3719            ]
     3720        },
     3721        "/wp/v2/media/(?P<id>[\\d]+)/finalize": {
     3722            "namespace": "wp/v2",
     3723            "methods": [
     3724                "POST"
     3725            ],
     3726            "endpoints": [
     3727                {
     3728                    "methods": [
     3729                        "POST"
     3730                    ],
     3731                    "args": {
     3732                        "id": {
     3733                            "description": "Unique identifier for the attachment.",
     3734                            "type": "integer",
     3735                            "required": false
     3736                        }
     3737                    }
     3738                }
     3739            ]
     3740        },
    36673741        "/wp/v2/menu-items": {
    36683742            "namespace": "wp/v2",
     
    1270012774        }
    1270112775    },
     12776    "image_sizes": {
     12777        "thumbnail": {
     12778            "width": 150,
     12779            "height": 150,
     12780            "crop": true
     12781        },
     12782        "medium": {
     12783            "width": 300,
     12784            "height": 300,
     12785            "crop": false
     12786        },
     12787        "medium_large": {
     12788            "width": 768,
     12789            "height": 0,
     12790            "crop": false
     12791        },
     12792        "large": {
     12793            "width": 1024,
     12794            "height": 1024,
     12795            "crop": false
     12796        },
     12797        "1536x1536": {
     12798            "width": 1536,
     12799            "height": 1536,
     12800            "crop": false
     12801        },
     12802        "2048x2048": {
     12803            "width": 2048,
     12804            "height": 2048,
     12805            "crop": false
     12806        }
     12807    },
     12808    "image_size_threshold": 2560,
     12809    "image_output_formats": {},
     12810    "jpeg_interlaced": false,
     12811    "png_interlaced": false,
     12812    "gif_interlaced": false,
    1270212813    "site_logo": 0,
    1270312814    "site_icon": 0,
  • trunk/tools/gutenberg/copy.js

    r62158 r62428  
    238238
    239239                        if ( entry.isDirectory() ) {
    240                                 // Skip plugin-only packages (e.g., vips/wasm) that should not be in Core.
    241                                 if ( entry.name === 'vips' ) {
    242                                         continue;
    243                                 }
    244240                                processDirectory( fullPath, baseDir );
    245241                        } else if ( entry.name.endsWith( '.min.asset.php' ) ) {
     
    325321                        if ( ! assetData.dependencies ) {
    326322                                assetData.dependencies = [];
    327                         }
    328 
    329                         // Strip plugin-only module dependencies (e.g., vips) that are not in Core.
    330                         if ( Array.isArray( assetData.module_dependencies ) ) {
    331                                 assetData.module_dependencies =
    332                                         assetData.module_dependencies.filter(
    333                                                 ( dep ) =>
    334                                                         ! ( dep.id || dep ).startsWith(
    335                                                                 '@wordpress/vips'
    336                                                         )
    337                                         );
    338323                        }
    339324
Note: See TracChangeset for help on using the changeset viewer.