diff --git a/Gruntfile.js b/Gruntfile.js index 61f18481e23a8..42c6c177e955c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -527,6 +527,7 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-admin/js/language-chooser.js' ]: [ './src/js/_enqueues/lib/language-chooser.js' ], [ WORKING_DIR + 'wp-admin/js/link.js' ]: [ './src/js/_enqueues/admin/link.js' ], [ WORKING_DIR + 'wp-admin/js/media-gallery.js' ]: [ './src/js/_enqueues/deprecated/media-gallery.js' ], + [ WORKING_DIR + 'wp-admin/js/media-library-upload.js' ]: [ './src/js/_enqueues/admin/media-library-upload.js' ], [ WORKING_DIR + 'wp-admin/js/media-upload.js' ]: [ './src/js/_enqueues/admin/media-upload.js' ], [ WORKING_DIR + 'wp-admin/js/media.js' ]: [ './src/js/_enqueues/admin/media.js' ], [ WORKING_DIR + 'wp-admin/js/nav-menu.js' ]: [ './src/js/_enqueues/lib/nav-menu.js' ], @@ -1270,6 +1271,7 @@ module.exports = function(grunt) { 'src/wp-admin/js/language-chooser.js': 'src/js/_enqueues/lib/language-chooser.js', 'src/wp-admin/js/link.js': 'src/js/_enqueues/admin/link.js', 'src/wp-admin/js/media-gallery.js': 'src/js/_enqueues/deprecated/media-gallery.js', + 'src/wp-admin/js/media-library-upload.js': 'src/js/_enqueues/admin/media-library-upload.js', 'src/wp-admin/js/media-upload.js': 'src/js/_enqueues/admin/media-upload.js', 'src/wp-admin/js/media.js': 'src/js/_enqueues/admin/media.js', 'src/wp-admin/js/nav-menu.js': 'src/js/_enqueues/lib/nav-menu.js', diff --git a/src/js/_enqueues/admin/media-library-upload.js b/src/js/_enqueues/admin/media-library-upload.js new file mode 100644 index 0000000000000..1f7514fcc4a84 --- /dev/null +++ b/src/js/_enqueues/admin/media-library-upload.js @@ -0,0 +1,436 @@ +/** + * Routes Media Library grid uploads through the client-side media pipeline. + * + * On wp-admin/upload.php (grid mode) WordPress uploads via wp.Uploader / + * plupload to async-upload.php. When the browser is cross-origin isolated + * and supports the client-side pipeline, this script intercepts the + * uploader's FilesAdded handler and routes files through + * @wordpress/upload-media instead: the original image is uploaded via the + * REST API and thumbnails are generated in the browser (wasm-vips), then + * sideloaded and finalized. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-library-upload.js + */ + +/* global plupload */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaLibraryUpload ) { + return; + } + + // Require every dependency the integration relies on. + if ( + typeof wp === 'undefined' || + typeof plupload === 'undefined' || + ! wp.Uploader || + ! wp.uploadMedia || + ! wp.mediaUtils || + ! wp.data || + ! wp.element || + ! wp.apiFetch || + ! wp.media + ) { + return; + } + + // Bail unless the browser actually supports client-side processing. This + // is the clean no-op: when the isolation headers did not land, classic + // plupload keeps handling uploads. + if ( + ! wp.uploadMedia.detectClientSideMediaSupport || + ! wp.uploadMedia.detectClientSideMediaSupport().supported + ) { + return; + } + + window.__wpMediaLibraryUpload = true; + + var __ = wp.i18n.__; + var settings = window._wpMediaLibraryUploadSettings || {}; + var uploadStore = wp.uploadMedia.store; + + // Map from a file identity key to the placeholder Attachment models + // (an array: concurrent uploads of an identical file share a key), used + // to reflect pipeline progress back onto the grid tiles. + var progressModels = new Map(); + + /** + * Builds a stable identity key for a File. + * + * The queue item's `sourceFile` is a clone of the original file, so it + * cannot be matched by reference. The clone preserves name, size, and + * last-modified time, which together identify a file within one session. + * Two in-flight uploads of the same file collide on this key, so keys + * map to arrays of models and progress is mirrored to all of them. + * + * @param {File} file The file to key. + * @return {string} Identity key. + */ + function fileKey( file ) { + return file.name + '::' + file.size + '::' + file.lastModified; + } + + /** + * Recursively appends data to a FormData object, supporting nested objects. + * + * Mirrors flattenFormData() in @wordpress/media-utils. + * + * @param {FormData} formData The form data to append to. + * @param {string} key The key to append under. + * @param {string|Object} data The value to append. + */ + function flattenFormData( formData, key, data ) { + if ( + data !== null && + typeof data === 'object' && + Object.getPrototypeOf( data ) === Object.prototype + ) { + Object.keys( data ).forEach( function ( name ) { + flattenFormData( formData, key + '[' + name + ']', data[ name ] ); + } ); + } else if ( data !== undefined ) { + formData.append( key, String( data ) ); + } + } + + /** + * Sideloads a client-generated thumbnail to an existing attachment. + * + * Reimplements the private sideloadMedia() helper from + * @wordpress/media-utils as a thin apiFetch wrapper. + * + * @param {Object} args The sideload arguments. + */ + function mediaSideload( args ) { + var file = args.file; + var additionalData = args.additionalData || {}; + + var data = new FormData(); + data.append( 'file', file, file.name || file.type.replace( '/', '.' ) ); + Object.keys( additionalData ).forEach( function ( key ) { + flattenFormData( data, key, additionalData[ key ] ); + } ); + + wp.apiFetch( { + path: '/wp/v2/media/' + args.attachmentId + '/sideload', + body: data, + method: 'POST', + signal: args.signal, + } ) + .then( function ( subSize ) { + if ( args.onSuccess ) { + args.onSuccess( subSize ); + } + } ) + .catch( function ( error ) { + if ( args.onError ) { + var normalized = error; + if ( ! ( error instanceof Error ) ) { + normalized = new Error( + error && error.message ? error.message : String( error ) + ); + } + args.onError( normalized ); + } + } ); + } + + /** + * Finalizes an upload once all client-side processing is complete. + * + * Reimplements the private mediaFinalize() helper. The returned + * attachment is load-bearing: it carries the post-finalize (scaled) + * URL used for srcset. + * + * @param {number} id The parent attachment ID. + * @param {Array} subSizes Accumulated sub-size data. + * @return {Promise} Resolves with the transformed attachment. + */ + function mediaFinalize( id, subSizes ) { + return wp + .apiFetch( { + path: '/wp/v2/media/' + id + '/finalize', + method: 'POST', + data: { sub_sizes: subSizes || [] }, + } ) + .then( function ( response ) { + if ( ! response ) { + return undefined; + } + return wp.mediaUtils.transformAttachment( response ); + } ); + } + + // Configure the default-registry upload-media store once. Rendering the + // provider with useSubRegistry: false wires the settings into the store + // that wp.data.dispatch/select address (the block editor does the same). + var pipelineSettings = { + mediaUpload: wp.mediaUtils.uploadMedia, + mediaSideload: mediaSideload, + mediaFinalize: mediaFinalize, + maxUploadFileSize: settings.maxUploadFileSize, + allowedMimeTypes: settings.allowedMimeTypes, + allImageSizes: settings.allImageSizes, + bigImageSizeThreshold: settings.bigImageSizeThreshold, + imageStripMeta: settings.imageStripMeta, + imageMaxBitDepth: settings.imageMaxBitDepth, + }; + + wp.element + .createRoot( document.createElement( 'div' ) ) + .render( + wp.element.createElement( wp.uploadMedia.MediaUploadProvider, { + settings: pipelineSettings, + useSubRegistry: false, + } ) + ); + + /** + * Resets the upload queue once every attachment has finished uploading. + * + * Parity with wp-plupload.js so browse mode flips back when done. + */ + function maybeResetQueue() { + var complete = wp.Uploader.queue.all( function ( attachment ) { + return ! attachment.get( 'uploading' ); + } ); + + if ( complete ) { + wp.Uploader.queue.reset(); + } + } + + /** + * Removes a model from the progress map. + * + * @param {Object} model The Attachment model to stop tracking. + */ + function stopTrackingProgress( model ) { + progressModels.forEach( function ( models, key ) { + var index = models.indexOf( model ); + if ( index !== -1 ) { + models.splice( index, 1 ); + } + if ( models.length === 0 ) { + progressModels.delete( key ); + } + } ); + } + + /** + * Handles a completed upload by syncing the grid tile with the server data. + * + * @param {Object} model The placeholder Attachment model. + * @param {Object} attachment The finalized attachment from the pipeline. + */ + function handleSuccess( model, attachment ) { + model.set( { id: attachment.id }, { silent: true } ); + + // Register the model in Attachments.all (parity with wp-plupload.js). + wp.media.model.Attachment.get( attachment.id, model ); + + model + .fetch() + .done( function () { + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( { uploading: false } ); + } ) + .fail( function () { + // Fetch failed, but the upload succeeded: clear the uploading + // state with what the pipeline gave us so no tile is stuck. + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( attachment ); + model.set( { uploading: false } ); + } ) + .always( function () { + stopTrackingProgress( model ); + maybeResetQueue(); + } ); + } + + /** + * Handles an upload error by removing the tile and surfacing the message. + * + * @param {Object} model The placeholder Attachment model. + * @param {Error} error The upload error. + * @param {File} nativeFile The original file (for the error label). + */ + function handleError( model, error, nativeFile ) { + var message = + ( wp.uploadMedia.getErrorMessage && + wp.uploadMedia.getErrorMessage( error ) ) || + ( error && error.message ) || + __( 'An error occurred while uploading the file.' ); + + model.destroy(); + + wp.Uploader.errors.unshift( { + message: message, + data: {}, + file: { name: nativeFile.name }, + } ); + + stopTrackingProgress( model ); + maybeResetQueue(); + } + + /** + * Dispatches a single file into the client-side pipeline. + * + * @param {File} nativeFile The original file to upload. + * @param {Object} model The placeholder Attachment model. + */ + function uploadFile( nativeFile, model ) { + wp.data.dispatch( uploadStore ).addItems( { + files: [ nativeFile ], + onSuccess: function ( attachments ) { + handleSuccess( model, attachments[ 0 ] ); + }, + onError: function ( error ) { + handleError( model, error, nativeFile ); + }, + } ); + } + + /** + * Intercepts files added to a plupload uploader. + * + * Returns undefined (not false) when the store is not yet configured so + * the built-in handler runs and uploads server-side - a degradation, never + * data loss. Otherwise builds the same placeholder tiles as wp-plupload, + * routes each file through the pipeline, and returns false to suppress the + * built-in handler. + * + * @param {Object} wpUploader The wp.Uploader instance. + * @param {Object} up The plupload uploader instance. + * @param {Array} files Files added to the queue. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function handleFilesAdded( wpUploader, up, files ) { + var storeSettings = wp.data.select( uploadStore ).getSettings(); + + // Safety valve: if settings never landed, defer to classic plupload. + if ( ! storeSettings || ! storeSettings.mediaUpload ) { + return; + } + + files.forEach( function ( file ) { + // Ignore failed uploads. + if ( plupload.FAILED === file.status ) { + return; + } + + // Build the same placeholder attributes as wp-plupload.js so the + // grid's progress tiles and "Uploading n/m" status work unchanged. + var attributes = { + file: file, + uploading: true, + date: new Date(), + filename: file.name, + menuOrder: 0, + uploadedTo: wp.media.model.settings.post.id, + loaded: file.loaded, + size: file.size, + percent: file.percent, + }; + + var image = /(?:jpe?g|png|gif)$/i.exec( file.name ); + if ( image ) { + attributes.type = 'image'; + // `jpg` is not a valid subtype, so map it to `jpeg`. + attributes.subtype = 'jpg' === image[ 0 ] ? 'jpeg' : image[ 0 ]; + } + + var model = wp.media.model.Attachment.create( attributes ); + wp.Uploader.queue.add( model ); + wpUploader.added( model ); + + var nativeFile = file.getNative(); + var key = fileKey( nativeFile ); + var models = progressModels.get( key ); + if ( models ) { + models.push( model ); + } else { + progressModels.set( key, [ model ] ); + } + + // Remove the file from plupload so it is not uploaded twice. + up.removeFile( file ); + + uploadFile( nativeFile, model ); + } ); + + up.refresh(); + + return false; + } + + // Wrap wp.Uploader.prototype.init (an empty stub called once per instance + // after plupload is initialized) to bind a higher-priority FilesAdded + // handler on every uploader instance, including the Media Library grid's. + var originalInit = wp.Uploader.prototype.init; + wp.Uploader.prototype.init = function () { + originalInit.apply( this, arguments ); + + var wpUploader = this; + var up = this.uploader; + + if ( ! up || up.__wpMediaLibraryUploadBound ) { + return; + } + up.__wpMediaLibraryUploadBound = true; + + // plupload sorts handlers by priority (descending) and a `false` + // return breaks the chain, so priority 100 runs before and suppresses + // the built-in FilesAdded handler. + up.bind( + 'FilesAdded', + function ( uploader, files ) { + return handleFilesAdded( wpUploader, uploader, files ); + }, + this, + 100 + ); + }; + + // Reflect pipeline progress onto the placeholder tiles. Progress is + // reported 0-100; hold at 99 until the model is marked done so the tile + // does not appear finished before the sync completes. + wp.data.subscribe( function () { + if ( progressModels.size === 0 ) { + return; + } + + var items = wp.data.select( uploadStore ).getItems(); + items.forEach( function ( item ) { + if ( ! item.sourceFile ) { + return; + } + + var models = progressModels.get( fileKey( item.sourceFile ) ); + if ( ! models ) { + return; + } + + if ( typeof item.progress === 'number' ) { + var percent = Math.min( 99, Math.round( item.progress ) ); + models.forEach( function ( model ) { + model.set( { percent: percent } ); + } ); + } + } ); + } ); +} )(); diff --git a/src/wp-admin/upload.php b/src/wp-admin/upload.php index 1f42a287e4957..edbbc942a7c23 100644 --- a/src/wp-admin/upload.php +++ b/src/wp-admin/upload.php @@ -141,6 +141,7 @@ wp_enqueue_media(); wp_enqueue_script( 'media-grid' ); wp_enqueue_script( 'media' ); + wp_enqueue_media_library_upload(); // Remove the error parameter added by deprecation of wp-admin/media.php. add_filter( diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 3702c17b418e8..a18636dea90b3 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -704,6 +704,7 @@ add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-upload.php', 'wp_set_up_media_library_cross_origin_isolation' ); // Nav menu. add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 ); add_filter( 'nav_menu_css_class', 'wp_nav_menu_remove_menu_item_has_children_class', 10, 4 ); diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index e5b4276e46af5..b5256063795cc 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6701,6 +6701,123 @@ function wp_set_up_cross_origin_isolation(): void { wp_start_cross_origin_isolation_output_buffer(); } +/** + * Returns the current Media Library mode (grid or list). + * + * Replicates the mode resolution in wp-admin/upload.php, which runs after + * the `load-upload.php` hook, without updating the saved user option. + * + * @since 7.2.0 + * + * @return string Either 'grid' or 'list'. + */ +function wp_get_media_library_mode(): string { + $modes = array( 'grid', 'list' ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], $modes, true ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $_GET['mode']; + } + + $mode = get_user_option( 'media_library_mode', get_current_user_id() ); + + return in_array( $mode, $modes, true ) ? $mode : 'grid'; +} + +/** + * Enables cross-origin isolation in the Media Library grid. + * + * Required for enabling SharedArrayBuffer for WebAssembly-based + * media processing when uploading via the Media Library grid. + * List mode has no client-side pipeline integration and is not + * isolated. + * + * @since 7.2.0 + */ +function wp_set_up_media_library_cross_origin_isolation(): void { + if ( ! wp_is_client_side_media_processing_enabled() ) { + return; + } + + if ( 'grid' !== wp_get_media_library_mode() ) { + return; + } + + // Cross-origin isolation is not needed if users can't upload files anyway. + if ( ! current_user_can( 'upload_files' ) ) { + return; + } + + wp_start_cross_origin_isolation_output_buffer(); +} + +/** + * Returns the settings for the client-side media processing pipeline + * in the Media Library. + * + * These mirror the values the block editor consumes for the same + * pipeline: the REST index (image sizes and the big-image threshold) + * and get_block_editor_settings() (max upload size and allowed mime + * types), plus the image encoding filters. + * + * @since 7.2.0 + * + * @return array { + * Settings for the client-side media processing pipeline. + * + * @type int $maxUploadFileSize Maximum upload file size in bytes. + * @type array $allowedMimeTypes Allowed mime types keyed by file extension. + * @type array $allImageSizes All registered image sub-sizes. + * @type int $bigImageSizeThreshold Threshold above which originals are scaled down. + * @type bool $imageStripMeta Whether metadata is stripped from generated images. + * @type int $imageMaxBitDepth Maximum bit depth for generated images. + * } + */ +function wp_get_media_library_upload_settings(): array { + /** This filter is documented in wp-admin/includes/image.php */ + $big_image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); + + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $image_strip_meta = (bool) apply_filters( 'image_strip_meta', true ); + + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $image_max_bit_depth = (int) apply_filters( 'image_max_bit_depth', 16, 16 ); + + return array( + 'maxUploadFileSize' => (int) wp_max_upload_size(), + 'allowedMimeTypes' => get_allowed_mime_types(), + 'allImageSizes' => wp_get_registered_image_subsizes(), + 'bigImageSizeThreshold' => $big_image_size_threshold, + 'imageStripMeta' => $image_strip_meta, + 'imageMaxBitDepth' => $image_max_bit_depth, + ); +} + +/** + * Enqueues the script that routes Media Library grid uploads through + * the client-side media processing pipeline. + * + * The script self-guards: when the browser is not cross-origin isolated + * or lacks client-side media support, it no-ops and the classic plupload + * flow keeps handling uploads. + * + * @since 7.2.0 + */ +function wp_enqueue_media_library_upload(): void { + if ( ! wp_is_client_side_media_processing_enabled() ) { + return; + } + + wp_enqueue_script( 'media-library-upload' ); + + wp_add_inline_script( + 'media-library-upload', + 'window._wpMediaLibraryUploadSettings = ' . wp_json_encode( wp_get_media_library_upload_settings() ) . ';', + 'before' + ); +} + /** * Sends the Document-Isolation-Policy header for cross-origin isolation. * diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index d777ecccc4373..18309abe2721e 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1515,6 +1515,9 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'media', "/wp-admin/js/media$suffix.js", array( 'jquery', 'clipboard', 'wp-i18n', 'wp-a11y' ), false, 1 ); $scripts->set_translations( 'media' ); + $scripts->add( 'media-library-upload', "/wp-admin/js/media-library-upload$suffix.js", array( 'media-views', 'wp-upload-media', 'wp-media-utils', 'wp-api-fetch', 'wp-data', 'wp-element', 'wp-i18n' ), false, 1 ); + $scripts->set_translations( 'media-library-upload' ); + $scripts->add( 'image-edit', "/wp-admin/js/image-edit$suffix.js", array( 'jquery', 'jquery-ui-core', 'imgareaselect', 'wp-a11y' ), false, 1 ); $scripts->set_translations( 'image-edit' ); diff --git a/tests/e2e/assets/test-image.jpg b/tests/e2e/assets/test-image.jpg new file mode 100644 index 0000000000000..534aac1d6bf49 Binary files /dev/null and b/tests/e2e/assets/test-image.jpg differ diff --git a/tests/e2e/specs/media-library-client-side-upload.test.js b/tests/e2e/specs/media-library-client-side-upload.test.js new file mode 100644 index 0000000000000..adc474cd195a3 --- /dev/null +++ b/tests/e2e/specs/media-library-client-side-upload.test.js @@ -0,0 +1,148 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * External dependencies + */ +import path from 'path'; + +const TEST_IMAGE_PATH = path.join( __dirname, '../assets/test-image.jpg' ); + +// The plupload HTML5 runtime creates this hidden file input over the +// "Add New" browse button; setting files on it triggers FilesAdded. +const FILE_INPUT_SELECTOR = '.moxie-shim-html5 input[type="file"]'; + +test.describe( 'Media Library grid client-side uploads', () => { + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test( 'sends the Document-Isolation-Policy header on the grid', async ( { + page, + admin, + } ) => { + const responsePromise = page.waitForResponse( + ( resp ) => + resp.url().includes( '/wp-admin/upload.php' ) && + resp.request().resourceType() === 'document' && + resp.status() === 200 + ); + + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const headers = ( await responsePromise ).headers(); + expect( headers[ 'document-isolation-policy' ] ).toBe( + 'isolate-and-credentialless' + ); + } ); + + test( 'does not send the Document-Isolation-Policy header in list mode', async ( { + page, + admin, + } ) => { + const responsePromise = page.waitForResponse( + ( resp ) => + resp.url().includes( '/wp-admin/upload.php' ) && + resp.request().resourceType() === 'document' && + resp.status() === 200 + ); + + await admin.visitAdminPage( 'upload.php', 'mode=list' ); + + const headers = ( await responsePromise ).headers(); + expect( headers[ 'document-isolation-policy' ] ).toBeUndefined(); + } ); + + test( 'uploads an image through the client-side pipeline', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + // Playwright's Chromium build lacks Document-Isolation-Policy + // support, so isolation is legitimately unavailable there and the + // pipeline falls back to classic uploads. Only assert where + // isolation is real. + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + // The REST route may be a pretty permalink (/wp/v2/media) or the + // plain form (index.php?rest_route=%2Fwp%2Fv2%2Fmedia), so match on + // the decoded URL. + let mediaCreateCount = 0; + let sideloadCount = 0; + let finalizeCount = 0; + const asyncUploads = []; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + const url = request.url(); + if ( url.includes( '/async-upload.php' ) ) { + asyncUploads.push( url ); + return; + } + const decoded = decodeURIComponent( url ); + if ( /\/wp\/v2\/media\/\d+\/sideload/.test( decoded ) ) { + sideloadCount++; + } else if ( /\/wp\/v2\/media\/\d+\/finalize/.test( decoded ) ) { + finalizeCount++; + } else if ( /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) ) { + mediaCreateCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // The finalized attachment resolves to a normal (non-uploading) tile. + await expect( + page.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + // The original upload and every sideload go through the REST API, + // and the upload is finalized exactly once. + expect( mediaCreateCount ).toBeGreaterThanOrEqual( 1 ); + expect( sideloadCount ).toBeGreaterThanOrEqual( 1 ); + expect( finalizeCount ).toBe( 1 ); + + // Nothing goes through the classic async-upload.php endpoint. + expect( asyncUploads ).toEqual( [] ); + } ); + + test( 'shows an error for a disallowed file type', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( { + name: 'disallowed.xyz', + mimeType: 'application/octet-stream', + buffer: Buffer.from( 'not an allowed file type' ), + } ); + + // The Manage frame renders rejected uploads in the error sidebar. + await expect( + page.locator( '.upload-error, .upload-errors' ).first() + ).toBeVisible( { timeout: 30_000 } ); + } ); +} ); diff --git a/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php new file mode 100644 index 0000000000000..7c804fe46c89b --- /dev/null +++ b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php @@ -0,0 +1,143 @@ +original_http_host = $_SERVER['HTTP_HOST'] ?? null; + + // A secure origin so client-side media processing is enabled. + $_SERVER['HTTP_HOST'] = 'localhost'; + + /* + * The script is registered in the admin-only branch of + * wp_default_scripts(), so default scripts must be (re)registered + * from an admin context. + */ + set_current_screen( 'upload' ); + $this->original_wp_scripts = $GLOBALS['wp_scripts'] ?? null; + $GLOBALS['wp_scripts'] = new WP_Scripts(); + } + + public function tear_down() { + if ( null === $this->original_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $this->original_http_host; + } + + $GLOBALS['wp_scripts'] = $this->original_wp_scripts; + $GLOBALS['current_screen'] = null; + + remove_all_filters( 'wp_client_side_media_processing_enabled' ); + parent::tear_down(); + } + + /** + * @ticket 65661 + */ + public function test_script_enqueued() { + wp_enqueue_media_library_upload(); + + $this->assertTrue( wp_script_is( 'media-library-upload', 'enqueued' ) ); + } + + /** + * @ticket 65661 + */ + public function test_script_not_enqueued_when_client_side_processing_disabled() { + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + wp_enqueue_media_library_upload(); + + $this->assertFalse( wp_script_is( 'media-library-upload', 'enqueued' ) ); + } + + /** + * The script depends on media-views and wp-upload-media, not + * wp-block-editor, so block editor bundles are not dragged onto + * the Media Library page. + * + * @ticket 65661 + */ + public function test_dependencies() { + wp_enqueue_media_library_upload(); + + $script = wp_scripts()->registered['media-library-upload']; + $this->assertContains( 'media-views', $script->deps ); + $this->assertContains( 'wp-upload-media', $script->deps ); + $this->assertNotContains( 'wp-block-editor', $script->deps ); + } + + /** + * @ticket 65661 + */ + public function test_inline_settings_expose_all_keys() { + wp_enqueue_media_library_upload(); + + $before = wp_scripts()->get_data( 'media-library-upload', 'before' ); + $inline = implode( "\n", (array) $before ); + + $this->assertStringContainsString( 'window._wpMediaLibraryUploadSettings', $inline ); + + foreach ( array( + 'maxUploadFileSize', + 'allowedMimeTypes', + 'allImageSizes', + 'bigImageSizeThreshold', + 'imageStripMeta', + 'imageMaxBitDepth', + ) as $key ) { + $this->assertStringContainsString( $key, $inline ); + } + } + + /** + * @ticket 65661 + */ + public function test_big_image_size_threshold_filter() { + add_filter( + 'big_image_size_threshold', + static function () { + return 4096; + } + ); + + $settings = wp_get_media_library_upload_settings(); + + $this->assertSame( 4096, $settings['bigImageSizeThreshold'] ); + } + + /** + * @ticket 65661 + */ + public function test_settings_value_types() { + $settings = wp_get_media_library_upload_settings(); + + $this->assertIsInt( $settings['maxUploadFileSize'] ); + $this->assertIsArray( $settings['allowedMimeTypes'] ); + $this->assertIsArray( $settings['allImageSizes'] ); + $this->assertIsInt( $settings['bigImageSizeThreshold'] ); + $this->assertIsBool( $settings['imageStripMeta'] ); + $this->assertIsInt( $settings['imageMaxBitDepth'] ); + } +} diff --git a/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php new file mode 100644 index 0000000000000..cf4be1219f82a --- /dev/null +++ b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php @@ -0,0 +1,230 @@ +original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $this->original_http_host = $_SERVER['HTTP_HOST'] ?? null; + $this->original_get_mode = $_GET['mode'] ?? null; + } + + public function tear_down() { + if ( null === $this->original_user_agent ) { + unset( $_SERVER['HTTP_USER_AGENT'] ); + } else { + $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent; + } + + if ( null === $this->original_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $this->original_http_host; + } + + if ( null === $this->original_get_mode ) { + unset( $_GET['mode'] ); + } else { + $_GET['mode'] = $this->original_get_mode; + } + + // Clean up any output buffers started during tests. + while ( ob_get_level() > 1 ) { + ob_end_clean(); + } + + remove_all_filters( 'wp_client_side_media_processing_enabled' ); + parent::tear_down(); + } + + /** + * Sets up the environment for the isolation happy path: a secure + * origin, a Chromium 137+ User-Agent, and a user who can upload. + */ + private function set_up_grid_isolation_environment() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + $_SERVER['HTTP_HOST'] = 'localhost'; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + } + + /** + * @ticket 65661 + */ + public function test_mode_defaults_to_grid() { + unset( $_GET['mode'] ); + + $this->assertSame( 'grid', wp_get_media_library_mode() ); + } + + /** + * @ticket 65661 + */ + public function test_mode_from_query_string() { + $_GET['mode'] = 'list'; + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + /** + * @ticket 65661 + */ + public function test_invalid_query_string_mode_falls_back_to_grid() { + $_GET['mode'] = 'bogus'; + + $this->assertSame( 'grid', wp_get_media_library_mode() ); + } + + /** + * A non-canonical query string mode is rejected, matching upload.php. + * + * upload.php compares the raw value strictly, so `?mode=GRID` falls + * back to the user option rather than being normalized to `grid`. + * + * @ticket 65661 + */ + public function test_non_canonical_query_string_mode_falls_back_to_user_option() { + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + update_user_option( $user_id, 'media_library_mode', 'list' ); + + $_GET['mode'] = 'GRID'; + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + /** + * @ticket 65661 + */ + public function test_mode_from_user_option() { + unset( $_GET['mode'] ); + + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + update_user_option( $user_id, 'media_library_mode', 'list' ); + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + /** + * @ticket 65661 + */ + public function test_no_buffer_when_client_side_processing_disabled() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * @ticket 65661 + */ + public function test_no_buffer_in_list_mode() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'list'; + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * @ticket 65661 + */ + public function test_no_buffer_when_logged_out() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + wp_set_current_user( 0 ); + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * @ticket 65661 + */ + public function test_no_buffer_when_user_cannot_upload() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * This test must run in a separate process because the output buffer + * callback sends HTTP headers via header(), which would fail in the + * main PHPUnit process where output has already started. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @ticket 65661 + */ + public function test_starts_output_buffer_in_grid_mode_for_chromium() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before + 1, $level_after, 'Output buffer should be started on the grid for Chromium 137+.' ); + + ob_end_clean(); + } + + /** + * @ticket 65661 + */ + public function test_no_buffer_for_firefox() { + $this->set_up_grid_isolation_environment(); + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0'; + $_GET['mode'] = 'grid'; + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for non-Chromium browsers.' ); + } +}