diff --git a/Gruntfile.js b/Gruntfile.js index 08da38fb748e2..5bc23092f8f1f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -530,6 +530,9 @@ 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-frame-upload.js' ]: [ './src/js/_enqueues/admin/media-frame-upload.js' ], + [ WORKING_DIR + 'wp-admin/js/media-new-upload.js' ]: [ './src/js/_enqueues/admin/media-new-upload.js' ], + [ WORKING_DIR + 'wp-admin/js/media-upload-pipeline.js' ]: [ './src/js/_enqueues/admin/media-upload-pipeline.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' ], @@ -1284,6 +1287,9 @@ 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-frame-upload.js': 'src/js/_enqueues/admin/media-frame-upload.js', + 'src/wp-admin/js/media-new-upload.js': 'src/js/_enqueues/admin/media-new-upload.js', + 'src/wp-admin/js/media-upload-pipeline.js': 'src/js/_enqueues/admin/media-upload-pipeline.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/package-lock.json b/package-lock.json index fd069ad47879c..e46c5864fb0e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@lodder/grunt-postcss": "^3.1.1", "@playwright/test": "1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "0.6.2", + "@types/backbone": "1.4.23", "@types/codemirror": "5.60.17", "@types/espree": "10.1.0", "@types/htmlhint": "1.1.5", @@ -5586,6 +5587,17 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/backbone": { + "version": "1.4.23", + "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.23.tgz", + "integrity": "sha512-B/hN/DAJdWFOusEkEoa5xgfVuxJJPOR/6JQ2uwURPDyKL24PuC76IR6EcSRJ6lvGWtxHRQYMJQhJYm6KpMDtGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jquery": "*", + "@types/underscore": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", diff --git a/package.json b/package.json index ca760568737a3..f97a9e5a81132 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@lodder/grunt-postcss": "^3.1.1", "@playwright/test": "1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "0.6.2", + "@types/backbone": "1.4.23", "@types/codemirror": "5.60.17", "@types/espree": "10.1.0", "@types/htmlhint": "1.1.5", diff --git a/src/js/_enqueues/admin/media-frame-upload.js b/src/js/_enqueues/admin/media-frame-upload.js new file mode 100644 index 0000000000000..fdd903b72fc46 --- /dev/null +++ b/src/js/_enqueues/admin/media-frame-upload.js @@ -0,0 +1,292 @@ +/** + * Routes media frame uploads through the client-side media pipeline. + * + * Every wp.media frame - the Media Library grid on wp-admin/upload.php and + * the media modal the block editor, the site editor and the block widgets + * screen open - 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 + * hands files to wp.mediaUploadPipeline (media-upload-pipeline.js) + * instead: the original image is uploaded via the REST API and thumbnails + * are generated in the browser (wasm-vips), then sideloaded and finalized. + * + * The frame's own UI is reused: the same placeholder tiles, progress bars, + * "Uploading n/m" status, and error list that wp-plupload.js drives. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-frame-upload.js + */ + +/* global plupload */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaFrameUpload ) { + return; + } + + const pipeline = window.wp && wp.mediaUploadPipeline; + + // 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 ( + ! pipeline || + typeof plupload === 'undefined' || + ! wp.Uploader || + ! wp.media || + ! pipeline.configure() + ) { + return; + } + + window.__wpMediaFrameUpload = true; + + /** + * Resets the upload queue once every attachment has finished uploading. + * + * Parity with wp-plupload.js so browse mode flips back when done. + */ + function maybeResetQueue() { + const complete = wp.Uploader.queue.all( function ( + /** @type {WPAttachment} */ attachment + ) { + return ! attachment.get( 'uploading' ); + } ); + + if ( complete ) { + wp.Uploader.queue.reset(); + } + } + + /** + * Handles a completed upload by syncing the grid tile with the server data. + * + * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file. + * @param {WPAttachment} model The placeholder Attachment model. + * @param {PipelineAttachment} attachment The finalized attachment from the pipeline. + */ + function handleSuccess( wpUploader, model, attachment ) { + // Not silent: Attachments re-keys a model from its cid to its id when + // it sees the change event. Without that the collection cannot find the + // attachment by id, and refreshing the library duplicates its tile. + model.set( { id: attachment.id } ); + + // Register the model in Attachments.all (parity with wp-plupload.js). + wp.media.model.Attachment.get( attachment.id, model ); + + // Clear the uploading state before the refetch lands, not after. The + // attachment details view re-renders on the refetch's changes, so it has + // to see `uploading: false` by then or it keeps showing a progress bar. + // wp-plupload.js sets both in one call for the same reason. + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( key ) { + model.unset( key, { silent: true } ); + } ); + model.set( { uploading: false }, { silent: true } ); + + model + .fetch() + .fail( function () { + // Fetch failed, but the upload succeeded: fill the tile with what + // the pipeline gave us so it is not left empty. + model.set( attachment ); + } ) + .always( function () { + maybeResetQueue(); + + // Parity with wp-plupload.js, which exposes this callback so + // other code can react to a finished upload. + wpUploader.success( model ); + } ); + } + + /** + * Handles an upload error by removing the tile and surfacing the message. + * + * The error goes into wp.Uploader.errors exactly like a classic upload + * error, so the grid's error sidebar renders it, announces it, and + * moves focus to its Dismiss button. + * + * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file. + * @param {WPAttachment} model The placeholder Attachment model. + * @param {UploadError} error The upload error. + * @param {File} nativeFile The original file (for the error label). + */ + function handleError( wpUploader, model, error, nativeFile ) { + const message = pipeline.getErrorText( error, nativeFile.name ); + const file = { name: nativeFile.name }; + + model.destroy(); + + wp.Uploader.errors.unshift( { + message: message, + data: {}, + file: file, + } ); + + maybeResetQueue(); + + // Parity with wp-plupload.js, which exposes this callback so other + // code can react to a failed upload. + wpUploader.error( message, {}, file ); + } + + /** + * Whether a plupload uploader posts attachments the way wp.Uploader does + * by default. + * + * The init wrapper below reaches every wp.Uploader built on the page, + * and a plugin can build one for its own purposes. Core's defaults tell + * an attachment upload apart: it posts to async-upload.php with the + * `upload-attachment` action. Anything a plugin pointed elsewhere, or + * gave another action, is uploading something else and is left alone. + * + * @param {plupload.Uploader} up The plupload uploader instance. + * @return {boolean} True when the uploader posts attachments. + */ + function isAttachmentUploader( up ) { + const settings = up.settings || {}; + const params = settings.multipart_params || {}; + const url = String( settings.url || '' ).split( '?' )[ 0 ]; + + return ( + 'upload-attachment' === params.action && + url.endsWith( 'async-upload.php' ) + ); + } + + /** + * Intercepts files added to a plupload uploader. + * + * Returns undefined (not false) when the pipeline cannot take the batch + * 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 {WPUploader} wpUploader The wp.Uploader instance. + * @param {plupload.Uploader} up The plupload uploader instance. + * @param {plupload.File[]} files Files added to the queue. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function handleFilesAdded( wpUploader, up, files ) { + if ( + ! isAttachmentUploader( up ) || + ! pipeline.isReady() || + ! pipeline.canHandleBatch( files ) + ) { + return; + } + + // The classic flow posts plupload's multipart params to + // async-upload.php; forward them so plugins reading $_POST see the + // same fields, with `post_id` spelled `post` for the REST API. + const params = ( up.settings && up.settings.multipart_params ) || {}; + const additionalData = pipeline.additionalDataFromParams( + params, + parseInt( params.post_id, 10 ) || 0 + ); + + 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. + /** @type {PlaceholderAttributes} */ + const 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, + }; + + /* + * Early mime type scanning for images, as wp-plupload.js does, + * extended with the formats the client-side pipeline accepts. + */ + const image = /(?:jpe?g|png|gif|webp|avif|heic|heif)$/i.exec( + file.name + ); + if ( image ) { + attributes.type = 'image'; + // `jpg` is not a valid subtype, so map it to `jpeg`. + const subtype = image[ 0 ].toLowerCase(); + attributes.subtype = 'jpg' === subtype ? 'jpeg' : subtype; + } + + const model = wp.media.model.Attachment.create( attributes ); + wp.Uploader.queue.add( model ); + wpUploader.added( model ); + + // canHandleBatch() already established that every file has one. + const nativeFile = /** @type {File} */ ( file.getNative() ); + + // Remove the file from plupload so it is not uploaded twice. + up.removeFile( file ); + + pipeline.queueFile( nativeFile, additionalData, { + onSuccess: function ( + /** @type {PipelineAttachment} */ attachment + ) { + handleSuccess( wpUploader, model, attachment ); + }, + onError: function ( /** @type {UploadError} */ error ) { + // When the server can convert what the browser could + // not, plupload takes the file from here and builds + // its own tile, so this one goes away. + if ( pipeline.handOffToClassic( up, error, nativeFile ) ) { + model.destroy(); + return; + } + handleError( wpUploader, model, error, nativeFile ); + }, + onProgress: function ( /** @type {number} */ percent ) { + model.set( { percent: percent } ); + }, + } ); + } ); + + 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: the grid's, and the one each media + // modal creates when it opens. + const originalInit = wp.Uploader.prototype.init; + wp.Uploader.prototype.init = function () { + originalInit.apply( this, arguments ); + + const wpUploader = /** @type {WPUploader} */ ( this ); + const up = /** @type {plupload.Uploader|undefined} */ ( this.uploader ); + + if ( ! up || up.__wpMediaFrameUploadBound ) { + return; + } + up.__wpMediaFrameUploadBound = 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 + ); + }; +} )(); diff --git a/src/js/_enqueues/admin/media-new-upload.js b/src/js/_enqueues/admin/media-new-upload.js new file mode 100644 index 0000000000000..4daa91e284e6f --- /dev/null +++ b/src/js/_enqueues/admin/media-new-upload.js @@ -0,0 +1,347 @@ +/** + * Routes "Add New Media File" uploads through the client-side media pipeline. + * + * On wp-admin/media-new.php WordPress uploads via a raw plupload.Uploader + * (created by plupload-handlers) posting 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 hands files + * to wp.mediaUploadPipeline (media-upload-pipeline.js) instead: the + * original image is uploaded via the REST API and thumbnails are generated + * in the browser (wasm-vips), then sideloaded and finalized. + * + * The screen's existing UI helpers from plupload-handlers are reused: + * fileQueued() builds the progress item, uploadSuccess() renders the + * finished attachment row (via the async-upload.php markup endpoint), and + * uploadComplete() runs when the queue drains. Failed uploads render the + * same notice async-upload.php returns for a server-side failure, so the + * screen looks and behaves unchanged. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-new-upload.js + */ + +/* global plupload, pluploadL10n, wpUploaderInit, fileQueued, uploadStart, uploadSuccess, uploadComplete */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaNewUpload ) { + return; + } + + const pipeline = window.wp && wp.mediaUploadPipeline; + + // 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 ( + ! pipeline || + typeof plupload === 'undefined' || + ! wp.a11y || + ! pipeline.configure() + ) { + return; + } + + window.__wpMediaNewUpload = true; + + const __ = wp.i18n.__; + const sprintf = wp.i18n.sprintf; + + // Number of pipeline uploads currently in flight, used to fire + // uploadComplete() when the queue drains. + let inFlightCount = 0; + + /** + * Marks one pipeline upload as finished, firing uploadComplete() when + * the queue drains. + * + * The built-in UploadComplete binding never fires for pipeline uploads + * because every file is removed from plupload before its queue starts. + */ + function finishUpload() { + inFlightCount--; + if ( inFlightCount === 0 ) { + uploadComplete(); + } + } + + /** + * Returns the post the screen attaches uploads to, or 0. + * + * media-new.php validates `post_id` (the post must exist and be + * editable by the user) before printing it into the form, whereas the + * raw request value is what plupload carries in its multipart params. + * async-upload.php re-validates that raw value and silently uploads + * unattached; the REST API rejects it instead, so read the validated one. + * + * @return {number} The parent post ID, or 0. + */ + function getParentPostId() { + const input = /** @type {HTMLInputElement|null} */ ( + document.getElementById( 'post_id' ) + ); + const postId = input ? parseInt( input.value, 10 ) : 0; + return postId > 0 ? postId : 0; + } + + /** + * Returns the screen's progress item for a plupload file. + * + * @param {plupload.File} file The plupload file. + * @return {HTMLElement|null} The item fileQueued() built, if it is still on the page. + */ + function getItem( file ) { + return document.getElementById( 'media-item-' + file.id ); + } + + /** + * Renders a failed upload the way async-upload.php does for a + * server-side failure: an error notice with a real Dismiss button + * described by the notice, a screen reader announcement, and focus + * returned to the browse button once dismissed. + * + * @param {plupload.File} file The plupload file that failed. + * @param {string} message The reason the upload failed. + */ + function renderError( file, message ) { + const item = getItem( file ); + if ( ! item ) { + return; + } + + const descriptionId = 'error-description-' + file.id; + + const button = document.createElement( 'button' ); + button.type = 'button'; + button.id = 'dismiss-' + file.id; + button.className = 'dismiss button-link'; + button.setAttribute( 'aria-describedby', descriptionId ); + button.textContent = pluploadL10n.dismiss; + + const heading = document.createElement( 'strong' ); + heading.textContent = pluploadL10n.error_uploading.replace( + '%s', + file.name + ); + + const notice = document.createElement( 'div' ); + notice.id = descriptionId; + notice.className = 'notice notice-error error-div error'; + notice.append( + button, + ' ', + heading, + document.createElement( 'br' ), + message + ); + + item.replaceChildren( notice ); + // Read by itemAjaxError() in plupload-handlers so a later server + // error for the same file is not rendered twice. + item.dataset.lastErr = file.id; + + setTimeout( function () { + wp.a11y.speak( + sprintf( + /* translators: %s: Name of the file that failed to upload. */ + __( '%s has failed to upload.' ), + file.name + ) + ); + }, 1500 ); + + button.addEventListener( 'click', function () { + item.remove(); + wp.a11y.speak( __( 'Error dismissed.' ) ); + const browseButton = document.getElementById( + 'plupload-browse-button' + ); + if ( browseButton ) { + browseButton.focus(); + } + } ); + } + + /** + * Reflects pipeline progress onto the screen's progress bar for a file. + * + * The bar is 200px wide at 100%, matching uploadProgress() in + * plupload-handlers. + * + * @param {plupload.File} file The plupload file being uploaded. + * @param {number} percent Progress percentage. + */ + function renderProgress( file, percent ) { + const item = getItem( file ); + if ( ! item ) { + return; + } + + const bar = /** @type {HTMLElement|null} */ ( + item.querySelector( '.bar' ) + ); + if ( bar ) { + bar.style.width = 2 * percent + 'px'; + } + + const label = item.querySelector( '.percent' ); + if ( label ) { + label.textContent = percent + '%'; + } + } + + /** + * Intercepts files added to the plupload uploader. + * + * Returns undefined (not false) when the pipeline cannot take the batch + * so the built-in handler runs and uploads server-side - a degradation, + * never data loss. Otherwise builds the screen's progress items, routes + * each file through the pipeline, and returns false to suppress the + * built-in handler (which would otherwise queue and start a classic + * upload). + * + * @param {plupload.Uploader} up The plupload uploader instance. + * @param {plupload.File[]} files Files added to the queue. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function handleFilesAdded( up, files ) { + if ( ! pipeline.isReady() || ! pipeline.canHandleBatch( files ) ) { + return; + } + + // Parity with the built-in handler: clear stale queue errors and run + // the shared upload-start housekeeping. + const queueErrors = document.getElementById( 'media-upload-error' ); + if ( queueErrors ) { + queueErrors.replaceChildren(); + } + uploadStart(); + + // The classic flow posts plupload's multipart params to + // async-upload.php; forward them so plugins reading $_POST see the + // same fields. Without the parent post a file uploaded from + // media-new.php?post_id=N would land unattached even though the + // screen counts it against that post. + const params = ( up.settings && up.settings.multipart_params ) || {}; + const additionalData = pipeline.additionalDataFromParams( + params, + getParentPostId() + ); + + files.forEach( function ( file ) { + // Ignore failed uploads. + if ( plupload.FAILED === file.status ) { + return; + } + + // Build the screen's progress item for this file. + fileQueued( file ); + + // canHandleBatch() already established that every file has one. + const nativeFile = /** @type {File} */ ( file.getNative() ); + + // Remove the file from plupload so it is not uploaded twice. + up.removeFile( file ); + + inFlightCount++; + + pipeline.queueFile( nativeFile, additionalData, { + onSuccess: function ( + /** @type {PipelineAttachment} */ attachment + ) { + // uploadSuccess() renders the finished attachment row via + // the existing async-upload.php markup endpoint; the + // server normally returns the ID as a string. + uploadSuccess( file, String( attachment.id ) ); + finishUpload(); + }, + onError: function ( /** @type {UploadError} */ error ) { + // When the server can convert what the browser could + // not, plupload takes the file from here and builds + // its own progress item, so this one goes away. + if ( pipeline.handOffToClassic( up, error, nativeFile ) ) { + const item = getItem( file ); + if ( item ) { + item.remove(); + } + finishUpload(); + return; + } + renderError( + file, + pipeline.getErrorText( error, nativeFile.name ) + ); + finishUpload(); + }, + onProgress: function ( /** @type {number} */ percent ) { + renderProgress( file, percent ); + }, + } ); + } ); + + up.refresh(); + + return false; + } + + /** + * Binds the interceptor to an uploader once it has initialized. + * + * 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. + * + * @param {plupload.Uploader} up The uploader that initialized. + */ + function bindUploader( up ) { + if ( up.__wpMediaNewUploadBound ) { + return; + } + up.__wpMediaNewUploadBound = true; + + up.bind( + 'FilesAdded', + function ( up, files ) { + return handleFilesAdded( up, files ); + }, + null, + 100 + ); + } + + // plupload-handlers creates its uploader from the `wpUploaderInit` + // settings once the DOM is ready, after this script has run, and + // plupload binds the handlers in a settings `init` map while the + // uploader initializes. Hooking PostInit there reaches the uploader + // without depending on ready-callback ordering. The settings are + // missing when the browser uploader is disabled (the html-uploader + // fallback), in which case there is nothing to intercept. + if ( typeof wpUploaderInit !== 'object' || ! wpUploaderInit ) { + return; + } + + // Either shape may already hold another script's handlers, which keep + // running ahead of the interceptor being bound. + const init = wpUploaderInit.init; + if ( typeof init === 'function' ) { + wpUploaderInit.init = function ( up ) { + init( up ); + bindUploader( up ); + }; + } else { + const postInit = init && init.PostInit; + wpUploaderInit.init = Object.assign( {}, init, { + PostInit: function ( + /** @type {plupload.Uploader} */ up, + /** @type {unknown[]} */ ...args + ) { + if ( typeof postInit === 'function' ) { + postInit.call( this, up, ...args ); + } + bindUploader( up ); + }, + } ); + } +} )(); diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js new file mode 100644 index 0000000000000..023a0c5de793c --- /dev/null +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -0,0 +1,656 @@ +/** + * Shared glue for routing classic admin uploads through the client-side + * media pipeline. + * + * The media frames (media-frame-upload.js) and the Add New Media + * File screen (media-new-upload.js) both intercept plupload and hand files + * to @wordpress/upload-media instead. Everything that is not specific to + * one screen's UI lives here: feature detection, configuring the + * upload-media store once, the REST sideload/finalize/delete helpers the + * store needs, queueing a file with success, error, and progress + * callbacks, the error text shown for a failed upload, and the guard that + * warns before leaving while uploads are in flight. + * + * Exposed as wp.mediaUploadPipeline. + * + * @output wp-admin/js/media-upload-pipeline.js + */ + +/* global plupload */ + +window.wp = window.wp || {}; + +( function ( wp ) { + const __ = wp.i18n.__; + const settings = window._wpMediaUploadPipelineSettings || {}; + /** @type {any} */ + let uploadStore; + let configured = false; + + // Number of queued files that have not succeeded or failed yet. + let inFlight = 0; + + // Uploads waiting to be matched to a queue item, keyed by file identity + // (concurrent uploads of an identical file share a key and are matched + // in order), and uploads already matched, keyed by queue item id. Items + // are matched by id from then on because the store swaps `sourceFile` + // for HEIC files once they are converted to JPEG. + const pending = new Map(); + const active = new Map(); + + // Files handed back to classic plupload after the pipeline gave up on + // them, so the interceptors let plupload's own handler take them. + const classicFiles = new WeakSet(); + + const imageSizeCount = Object.keys( settings.allImageSizes || {} ).length; + + /** + * 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. + * + * @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 the media-utils package. + * + * @param {FormData} formData The form data to append to. + * @param {string} key The key to append under. + * @param {*} 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 the media-utils + * package as a thin apiFetch wrapper. + * + * @param {Object} args The sideload arguments. + * @param {File} args.file The sub-size to sideload. + * @param {number} args.attachmentId The attachment to sideload it to. + * @param {Record} [args.additionalData] Extra fields to send with it. + * @param {AbortSignal} [args.signal] Signal aborting the request. + * @param {( subSize: Object ) => void} [args.onSuccess] Called with the sideloaded sub-size. + * @param {( error: Error ) => void} [args.onError] Called when the sideload failed. + */ + function mediaSideload( args ) { + const file = args.file; + const additionalData = args.additionalData || {}; + + const 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 ( /** @type {Object} */ subSize ) { + if ( args.onSuccess ) { + args.onSuccess( subSize ); + } + } ) + .catch( function ( /** @type {unknown} */ error ) { + if ( args.onError ) { + let normalized = error; + if ( ! ( error instanceof Error ) ) { + const rest = /** @type {UploadError} */ ( error ); + normalized = new Error( + rest && rest.message ? rest.message : String( error ) + ); + } + args.onError( /** @type {Error} */ ( 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 {Object[]} 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 ( /** @type {Object|undefined} */ response ) { + if ( ! response ) { + return undefined; + } + return wp.mediaUtils.transformAttachment( response ); + } ); + } + + /** + * Deletes an attachment whose client-side processing failed outright. + * + * The queue calls this when every sub-size sideload for an upload fails: + * without it the original file is left behind as an attachment with no + * metadata, visible in the Media Library after the next page load. The + * block editor passes the same setting. + * + * @param {number} id The attachment ID to delete. + * @return {Promise} Resolves once the attachment is deleted. + */ + function mediaDelete( id ) { + return wp.apiFetch( { + path: '/wp/v2/media/' + id + '?force=true', + method: 'DELETE', + } ); + } + + /** + * Whether every script the pipeline relies on is present. + * + * @return {boolean} True when the dependencies loaded. + */ + function hasDependencies() { + return Boolean( + typeof plupload !== 'undefined' && + wp.uploadMedia && + wp.mediaUtils && + wp.data && + wp.element && + wp.apiFetch + ); + } + + /** + * Whether the browser can run the client-side pipeline on this page. + * + * False when the isolation headers did not land (the page is not + * cross-origin isolated) or the browser lacks the required features, + * in which case classic plupload keeps handling uploads. + * + * @return {boolean} True when client-side processing is available. + */ + function isSupported() { + return Boolean( + hasDependencies() && + wp.uploadMedia.detectClientSideMediaSupport && + wp.uploadMedia.detectClientSideMediaSupport().supported + ); + } + + /** + * Estimates the progress (0-100) of a queue item. + * + * The pipeline never reports a numeric `progress` on its queue items + * (nothing dispatches updateItemProgress), so estimate one from the + * item's operation queue instead: each finished operation (prepare, + * transcode, upload, thumbnails, finalize) advances the bar, and the + * sub-sizes sideloaded so far advance it within thumbnail generation. + * `item.progress` is preferred whenever it is present. + * + * @param {QueueItem} item The upload-media queue item. + * @param {UploadEntry} entry The pipeline's bookkeeping for the upload. + * @return {number} Estimated progress. + */ + function estimateProgress( item, entry ) { + if ( typeof item.progress === 'number' ) { + return item.progress; + } + + const remaining = item.operations ? item.operations.length : 0; + let totals = entry.totals; + if ( ! totals ) { + totals = { total: remaining, remaining: remaining }; + entry.totals = totals; + } + // Operations are appended after preparation, so grow the total. + if ( remaining > totals.remaining ) { + totals.total += remaining - totals.remaining; + } + totals.remaining = remaining; + + if ( totals.total === 0 ) { + return 0; + } + + const completed = totals.total - remaining; + let fraction = 0; + if ( + 'THUMBNAIL_GENERATION' === item.currentOperation && + imageSizeCount > 0 + ) { + fraction = Math.min( + 1, + ( item.subSizes || [] ).length / imageSizeCount + ); + } + + return ( ( completed + fraction ) / totals.total ) * 100; + } + + /** + * Matches queue items to queued uploads and reports their progress. + * + * Runs on every change to the upload-media store. Sub-size children + * carry the parent's file and are skipped; only top-level items drive + * the screen's progress UI. Progress holds at 99 until the upload's + * success callback has run, so nothing looks finished before the + * screen has synced the result. + */ + function onStoreChange() { + if ( inFlight === 0 ) { + return; + } + + const items = wp.data.select( uploadStore ).getItems(); + items.forEach( function ( /** @type {QueueItem} */ item ) { + if ( item.parentId || ! item.sourceFile ) { + return; + } + + let entry = active.get( item.id ); + if ( ! entry ) { + const key = fileKey( item.sourceFile ); + const list = pending.get( key ); + if ( ! list || ! list.length ) { + return; + } + entry = list.shift(); + if ( ! list.length ) { + pending.delete( key ); + } + entry.itemId = item.id; + active.set( item.id, entry ); + } + + if ( ! entry.onProgress ) { + return; + } + + const percent = Math.min( + 99, + Math.round( estimateProgress( item, entry ) ) + ); + if ( percent !== entry.lastPercent ) { + entry.lastPercent = percent; + entry.onProgress( percent ); + } + } ); + } + + /** + * Stops tracking an upload once it has succeeded or failed. + * + * @param {UploadEntry} entry The pipeline's bookkeeping for the upload. + */ + function release( entry ) { + if ( entry.released ) { + return; + } + entry.released = true; + inFlight--; + + const list = pending.get( entry.key ); + if ( list ) { + const index = list.indexOf( entry ); + if ( index !== -1 ) { + list.splice( index, 1 ); + } + if ( ! list.length ) { + pending.delete( entry.key ); + } + } + + if ( entry.itemId ) { + active.delete( entry.itemId ); + } + } + + /** + * Whether something else already configured the store on this page. + * + * The block editor renders its own MediaUploadProvider into the same + * store. Neither `mediaSideload` nor `mediaFinalize` has a default, so + * their presence proves a provider's settings landed, whichever one + * rendered them. + * + * @return {boolean} True when the store carries a provider's settings. + */ + function isStoreConfigured() { + const storeSettings = wp.data.select( uploadStore ).getSettings(); + return Boolean( + storeSettings && + storeSettings.mediaSideload && + storeSettings.mediaFinalize + ); + } + + /** + * Configures the upload-media store for this page, 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 for its own uploads, so on a screen where it already + * has, its settings are used as they are and no second provider is + * rendered. + * + * @return {boolean} True when the pipeline is configured and usable. + */ + function configure() { + if ( configured ) { + return true; + } + + if ( ! isSupported() ) { + return false; + } + + configured = true; + uploadStore = wp.uploadMedia.store; + + if ( isStoreConfigured() ) { + wp.data.subscribe( onStoreChange, uploadStore ); + addUnloadGuard(); + return true; + } + + /* + * The media-utils package branches on this flag: without it + * uploadMedia() creates and revokes a throwaway blob URL per file + * and emits an extra onFileChange carrying it. The block editor + * sets the same flag before configuring the same pipeline. + */ + window.__clientSideMediaProcessing = true; + + wp.element + .createRoot( document.createElement( 'div' ) ) + .render( + wp.element.createElement( wp.uploadMedia.MediaUploadProvider, { + settings: { + mediaUpload: wp.mediaUtils.uploadMedia, + mediaSideload: mediaSideload, + mediaFinalize: mediaFinalize, + mediaDelete: mediaDelete, + maxUploadFileSize: settings.maxUploadFileSize, + allowedMimeTypes: settings.allowedMimeTypes, + allImageSizes: settings.allImageSizes, + bigImageSizeThreshold: settings.bigImageSizeThreshold, + imageStripMeta: settings.imageStripMeta, + imageMaxBitDepth: settings.imageMaxBitDepth, + }, + useSubRegistry: false, + } ) + ); + + // Only the upload-media store can change what this listener reads. + wp.data.subscribe( onStoreChange, uploadStore ); + addUnloadGuard(); + + return true; + } + + /** + * Warns before leaving while uploads are in flight. + * + * Thumbnails that have not been sideloaded yet are lost and the + * attachment is left unfinalized, unlike classic uploads that complete + * server-side once the bytes arrive. + */ + function addUnloadGuard() { + window.addEventListener( 'beforeunload', function ( event ) { + if ( inFlight > 0 ) { + event.preventDefault(); + // Some Chromium versions only show the prompt for returnValue. + event.returnValue = ''; + } + } ); + } + + /** + * Whether the store has received a provider's settings. + * + * A provider renders asynchronously, so a file added before its + * settings land must be left to classic plupload - a degradation, + * never data loss. The store's default state already carries a no-op + * `mediaUpload`, so only settings a provider alone supplies prove the + * real ones have landed: a file queued before that would be handed to + * the no-op and never upload. The block editor's provider counts too: + * when it renders after this script's, its settings replace them, and + * they drive the same pipeline. + * + * @return {boolean} True when the store is ready to accept files. + */ + function isReady() { + return configured && isStoreConfigured(); + } + + /** + * Whether a batch of plupload files can be routed through the pipeline. + * + * Suppressing plupload's built-in FilesAdded handler is all-or-nothing, + * so the whole batch stays on the classic path when any file cannot be + * handled: plupload returns no native File for sources it cannot expose + * as one (the html4 runtime, say), audio files are left to the classic + * upload so they keep the title and description that + * media_handle_upload() derives from their ID3 tags, which the REST + * endpoint does not do, and a file the pipeline already handed back to + * plupload (see handOffToClassic()) is plupload's to upload. + * + * @param {plupload.File[]} files Files added to the plupload queue. + * @return {boolean} True when every file can go through the pipeline. + */ + function canHandleBatch( files ) { + return files.every( function ( file ) { + if ( plupload.FAILED === file.status ) { + return true; + } + const nativeFile = file.getNative && file.getNative(); + if ( ! nativeFile || classicFiles.has( nativeFile ) ) { + return false; + } + return ! /^audio\//i.test( file.type || '' ); + } ); + } + + /** + * Re-queues a file on classic plupload when the server can do what the + * browser could not. + * + * The pipeline converts HEIC in the browser and fails outright when the + * platform has no HEIC decoder (Linux, and Windows without the HEVC + * extension), whereas the classic upload lets the server convert it. + * When the server's image editor supports HEIC - plupload carries no + * `heic_upload_error` setting then - the file goes back to plupload, + * whose own FilesAdded handler queues and starts the classic upload. + * Nothing else can convert it, so any other failure stands. + * + * @param {plupload.Uploader} up The plupload uploader the file came from. + * @param {UploadError} error Why the pipeline gave up on it. + * @param {File} nativeFile The file the pipeline was given. + * @return {boolean} True when the file was handed to classic plupload. + */ + function handOffToClassic( up, error, nativeFile ) { + const errorCodes = wp.uploadMedia.ErrorCode || {}; + if ( + ! error || + ! errorCodes.HEIC_DECODE_ERROR || + error.code !== errorCodes.HEIC_DECODE_ERROR || + ! up || + typeof up.addFile !== 'function' || + ( up.settings && up.settings.heic_upload_error ) + ) { + return false; + } + + classicFiles.add( nativeFile ); + up.addFile( nativeFile ); + return true; + } + + /** + * Builds the extra fields to send with an upload from plupload's + * multipart params. + * + * Anything a plugin added through the `plupload_default_params` filter + * or wp.Uploader.param() reached the classic upload as $_POST fields, + * so it is forwarded to the REST request the same way. The classic + * transport's own fields are dropped: `action` and `_wpnonce` belong to + * async-upload.php, and `post_id` is spelled `post` by the REST API and + * passed separately after the screen has validated it. + * + * @param {Record} params Plupload's multipart_params. + * @param {number} postId The validated post to attach the upload to, or 0. + * @return {Record} Additional data for the upload. + */ + function additionalDataFromParams( params, postId ) { + /** @type {Record} */ + const additionalData = {}; + Object.keys( params || {} ).forEach( function ( key ) { + if ( 'action' === key || '_wpnonce' === key || 'post_id' === key ) { + return; + } + additionalData[ key ] = params[ key ]; + } ); + if ( postId ) { + additionalData.post = postId; + } + return additionalData; + } + + /** + * Queues a file for client-side processing and upload. + * + * @param {File} nativeFile The file to upload. + * @param {Record} additionalData Extra fields to send with the attachment. + * @param {QueueFileCallbacks} [callbacks] Lifecycle callbacks. + */ + function queueFile( nativeFile, additionalData, callbacks ) { + callbacks = callbacks || {}; + + const entry = { + key: fileKey( nativeFile ), + itemId: null, + onProgress: callbacks.onProgress, + lastPercent: -1, + totals: null, + released: false, + }; + + const list = pending.get( entry.key ); + if ( list ) { + list.push( entry ); + } else { + pending.set( entry.key, [ entry ] ); + } + inFlight++; + + wp.data.dispatch( uploadStore ).addItems( { + files: [ nativeFile ], + additionalData: additionalData || {}, + onSuccess: function ( + /** @type {PipelineAttachment[]} */ attachments + ) { + release( entry ); + if ( callbacks.onSuccess ) { + callbacks.onSuccess( attachments[ 0 ] ); + } + }, + onError: function ( /** @type {UploadError} */ error ) { + release( entry ); + if ( callbacks.onError ) { + callbacks.onError( error ); + } + }, + } ); + } + + /** + * Builds the display text for a failed upload. + * + * wp.uploadMedia.getErrorMessage() maps an error *code* and a file name + * to a { title, description, action } object, so it can neither be handed + * the Error itself nor used as a string. Only codes it actually maps are + * worth using: its fallback (and the GENERAL code) says nothing the + * error's own message does not, and preferring the message there keeps a + * server-supplied reason instead of replacing it with "Please try again." + * + * @param {UploadError} error The upload error. + * @param {string} fileName Name of the file that failed to upload. + * @return {string} A human-readable message. + */ + function getErrorText( error, fileName ) { + const errorCodes = wp.uploadMedia.ErrorCode || {}; + const code = error && error.code; + let details; + + if ( + code && + code !== errorCodes.GENERAL && + Object.prototype.hasOwnProperty.call( errorCodes, code ) && + wp.uploadMedia.getErrorMessage + ) { + details = wp.uploadMedia.getErrorMessage( code, fileName ); + + if ( details && details.description ) { + return details.action ? + details.description + ' ' + details.action : + details.description; + } + } + + return ( + ( error && error.message ) || + __( 'An error occurred while uploading the file.' ) + ); + } + + /** + * Whether any queued upload has not finished yet. + * + * @return {boolean} True while uploads are in flight. + */ + function hasInFlight() { + return inFlight > 0; + } + + wp.mediaUploadPipeline = { + isSupported: isSupported, + configure: configure, + isReady: isReady, + canHandleBatch: canHandleBatch, + handOffToClassic: handOffToClassic, + additionalDataFromParams: additionalDataFromParams, + queueFile: queueFile, + getErrorText: getErrorText, + hasInFlight: hasInFlight, + }; +} )( window.wp ); diff --git a/src/wp-admin/media-new.php b/src/wp-admin/media-new.php index 7be28743808fd..1e4e35c08405d 100644 --- a/src/wp-admin/media-new.php +++ b/src/wp-admin/media-new.php @@ -17,6 +17,7 @@ } wp_enqueue_script( 'plupload-handlers' ); +wp_enqueue_media_new_upload(); $post_id = 0; if ( isset( $_REQUEST['post_id'] ) ) { diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 025a371781200..b6bb805a9998f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -704,6 +704,8 @@ 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_cross_origin_isolation' ); +add_action( 'load-media-new.php', 'wp_set_up_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 1e0b86655e82a..5d31adb25a99f 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4913,6 +4913,7 @@ function wp_prepare_attachment_for_js( $attachment ) { * all media JS APIs. * * @since 3.5.0 + * @since 7.2.0 Also enqueues the client-side upload integration for the media frame on cross-origin isolated screens. * * @global int $content_width * @global wpdb $wpdb WordPress database abstraction object. @@ -5302,6 +5303,7 @@ function wp_enqueue_media( $args = array() ) { * is registered internally before we try to localize it. See #24724. */ wp_enqueue_script( 'media-editor' ); + wp_enqueue_media_frame_upload(); wp_localize_script( 'media-views', '_wpMediaViewsL10n', $strings ); wp_enqueue_script( 'media-audiovideo' ); @@ -6608,9 +6610,7 @@ function wp_set_client_side_media_processing_flag(): void { wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' ); - $chromium_version = wp_get_chromium_major_version(); - - if ( null !== $chromium_version && $chromium_version >= 137 ) { + if ( wp_is_document_isolation_policy_supported() ) { wp_add_inline_script( 'wp-block-editor', 'window.__documentIsolationPolicy = true;', 'before' ); } } @@ -6635,17 +6635,42 @@ function wp_get_chromium_major_version(): ?int { } /** - * Enables cross-origin isolation in the block editor. + * Determines whether the current request's browser honors the + * Document-Isolation-Policy header. + * + * Document-Isolation-Policy is how WordPress makes a page cross-origin + * isolated for client-side media processing. Chromium 137+ is the only + * engine that implements it; other browsers ignore the header, so the + * page never becomes isolated and the client-side pipeline cannot run. + * + * @since 7.2.0 + * + * @return bool True when the browser is Chromium 137 or newer. + */ +function wp_is_document_isolation_policy_supported(): bool { + $chromium_version = wp_get_chromium_major_version(); + + return null !== $chromium_version && $chromium_version >= 137; +} + +/** + * Enables cross-origin isolation on screens that upload media client-side. * * Required for enabling SharedArrayBuffer for WebAssembly-based - * media processing in the editor. Uses Document-Isolation-Policy + * media processing in the block editor, the Media Library grid, and + * the "Add New Media File" screen. Uses Document-Isolation-Policy * on supported browsers (Chromium 137+). * + * The Media Library is only isolated in grid mode: list mode has no + * client-side pipeline integration, its uploads go through the + * "Add New Media File" screen. + * * Skips setup when a third-party page builder overrides the block * editor via a custom `action` query parameter, as DIP would block * same-origin iframe access that these editors rely on. * * @since 7.1.0 + * @since 7.2.0 Also isolates the Media Library grid and the "Add New Media File" screen. */ function wp_set_up_cross_origin_isolation(): void { if ( ! wp_is_client_side_media_processing_enabled() ) { @@ -6658,7 +6683,14 @@ function wp_set_up_cross_origin_isolation(): void { return; } - if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) { + $is_media_screen = 'media' === $screen->id || ( 'upload' === $screen->id && 'grid' === wp_get_media_library_mode() ); + + if ( + ! $is_media_screen && + ! $screen->is_block_editor() && + 'site-editor' !== $screen->id && + ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) + ) { return; } @@ -6683,7 +6715,7 @@ function wp_set_up_cross_origin_isolation(): void { * DIP isolates the document into its own agent cluster, * which blocks same-origin iframe access that these editors rely on. */ - if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) { + if ( ! $is_media_screen && isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) { return; } @@ -6695,6 +6727,152 @@ 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. + * + * upload.php falls back to grid mode for any falsey saved value and renders + * grid mode only for the exact value 'grid'. A truthy saved value outside the + * two known modes - one a plugin stored, say - therefore renders list mode + * there and resolves to 'list' here, so callers do not isolate a page that + * the Media Library renders in list mode. + * + * @since 7.2.0 + * + * @return string The Media Library mode, 'grid' when none is saved. + * @phpstan-return 'grid'|'list' + */ +function wp_get_media_library_mode(): string { + $valid_modes = array( 'grid', 'list' ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], $valid_modes, true ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $_GET['mode']; + } + + $mode = get_user_option( 'media_library_mode', get_current_user_id() ); + + if ( in_array( $mode, $valid_modes, true ) ) { + return $mode; + } + + // As in upload.php: nothing saved renders the grid, any other value the list. + return $mode ? 'list' : 'grid'; +} + +/** + * 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 a screen's client-side upload integration script along with the + * shared pipeline glue and its settings. + * + * Nothing is enqueued unless client-side media processing is enabled and the + * browser honors Document-Isolation-Policy: without isolation the page never + * becomes cross-origin isolated, the script would no-op, and the whole + * wp-upload-media dependency chain would be loaded for nothing. The same + * goes for a screen wp_set_up_cross_origin_isolation() did not isolate, + * such as the Customizer or the Site Icon picker: the media frame loads + * there too, but its uploads stay on the classic path. + * + * The screen script self-guards at runtime too: when the browser turns out + * not to be isolated or lacks client-side media support, it no-ops and the + * classic plupload flow keeps handling uploads. + * + * @since 7.2.0 + * @access private + * + * @param string $handle The screen script to enqueue. + */ +function _wp_enqueue_media_upload_pipeline_script( string $handle ): void { + if ( ! wp_is_client_side_media_processing_enabled() ) { + return; + } + + if ( ! wp_is_document_isolation_policy_supported() ) { + return; + } + + if ( ! wp_is_cross_origin_isolated_request() ) { + return; + } + + wp_enqueue_script( $handle ); + + wp_add_inline_script( + 'media-upload-pipeline', + 'window._wpMediaUploadPipelineSettings = ' . wp_json_encode( wp_get_media_library_upload_settings() ) . ';', + 'before' + ); +} + +/** + * Enqueues the script that routes uploads started from a media frame + * through the client-side media processing pipeline. + * + * Covers every `wp.media` frame that uploads through `wp.Uploader`: the + * Media Library grid and the media modal the block editor, the site editor + * and the block widgets screen open. wp_enqueue_media() calls this on any + * screen that was cross-origin isolated, so it rarely needs calling directly. + * + * @since 7.2.0 + */ +function wp_enqueue_media_frame_upload(): void { + _wp_enqueue_media_upload_pipeline_script( 'media-frame-upload' ); +} + +/** + * Enqueues the script that routes "Add New Media File" screen uploads + * through the client-side media processing pipeline. + * + * @since 7.2.0 + */ +function wp_enqueue_media_new_upload(): void { + _wp_enqueue_media_upload_pipeline_script( 'media-new-upload' ); +} + /** * Sends the Document-Isolation-Policy header for cross-origin isolation. * @@ -6703,9 +6881,7 @@ function wp_set_up_cross_origin_isolation(): void { * @since 7.1.0 */ function wp_start_cross_origin_isolation_output_buffer(): void { - $chromium_version = wp_get_chromium_major_version(); - - if ( null === $chromium_version || $chromium_version < 137 ) { + if ( ! wp_is_document_isolation_policy_supported() ) { return; } @@ -6716,6 +6892,24 @@ static function ( string $output ): string { return wp_add_crossorigin_attributes( $output ); } ); + + $GLOBALS['_wp_cross_origin_isolated'] = true; +} + +/** + * Whether the current request has been set up for cross-origin isolation. + * + * True once wp_set_up_cross_origin_isolation() has armed the + * Document-Isolation-Policy header for the screen being rendered, which is + * what lets scripts that need a cross-origin isolated page, such as the + * client-side media upload integration, decide whether to load at all. + * + * @since 7.2.0 + * + * @return bool True when the response will be cross-origin isolated. + */ +function wp_is_cross_origin_isolated_request(): bool { + return ! empty( $GLOBALS['_wp_cross_origin_isolated'] ); } /** diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 7b93f6a5c1f5d..914d9eec781e1 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1516,6 +1516,14 @@ 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-upload-pipeline', "/wp-admin/js/media-upload-pipeline$suffix.js", array( 'plupload', 'wp-upload-media', 'wp-media-utils', 'wp-api-fetch', 'wp-data', 'wp-element', 'wp-i18n' ), false, 1 ); + $scripts->set_translations( 'media-upload-pipeline' ); + + $scripts->add( 'media-frame-upload', "/wp-admin/js/media-frame-upload$suffix.js", array( 'media-views', 'media-upload-pipeline' ), false, 1 ); + + $scripts->add( 'media-new-upload', "/wp-admin/js/media-new-upload$suffix.js", array( 'plupload-handlers', 'media-upload-pipeline', 'wp-a11y', 'wp-i18n' ), false, 1 ); + $scripts->set_translations( 'media-new-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-audio.wav b/tests/e2e/assets/test-audio.wav new file mode 100644 index 0000000000000..92349197e812f Binary files /dev/null and b/tests/e2e/assets/test-audio.wav differ diff --git a/tests/e2e/assets/test-image.jpg b/tests/e2e/assets/test-image.jpg new file mode 100644 index 0000000000000..938be00cdec1b 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..1b89f5e76b7f7 --- /dev/null +++ b/tests/e2e/specs/media-library-client-side-upload.test.js @@ -0,0 +1,730 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * External dependencies + */ +import path from 'path'; + +// A 640x480 image: it must be larger than at least one registered sub-size +// (thumbnail, medium) so the pipeline generates and sideloads thumbnails. +const TEST_IMAGE_PATH = path.join( __dirname, '../assets/test-image.jpg' ); + +// A short WAV file: audio stays on the classic upload path so it keeps the +// ID3-derived title and description that media_handle_upload() sets. +const TEST_AUDIO_PATH = path.join( __dirname, '../assets/test-audio.wav' ); + +// 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"]'; + +// A REST error the pipeline surfaces as-is: its message matches none of the +// transient patterns @wordpress/upload-media retries on. +const SIMULATED_ERROR = { + code: 'rest_upload_simulated_failure', + message: 'Simulated server failure for testing', + data: { status: 500 }, +}; + +/** + * Fails every REST media create request (not sideload/finalize). + * + * @param {import('@playwright/test').Page} page + */ +async function failMediaCreate( page ) { + await page.route( + ( url ) => { + const decoded = decodeURIComponent( url.href ); + return ( + /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) && + ! /\/(sideload|finalize)/.test( decoded ) + ); + }, + async ( route ) => { + if ( route.request().method() !== 'POST' ) { + await route.continue(); + return; + } + await route.fulfill( { + status: 500, + contentType: 'application/json', + body: JSON.stringify( SIMULATED_ERROR ), + } ); + } + ); +} + +// A file plupload and the store both treat as HEIC but no browser can +// decode: whether the server can convert it is what decides its path. +const UNDECODABLE_HEIC = { + name: 'undecodable.heic', + mimeType: 'image/heic', + buffer: Buffer.from( 'not a HEIC image' ), +}; + +/** + * Counts the POST requests that upload a file, per transport. + * + * @param {import('@playwright/test').Page} page + * @return {{ asyncUpload: number, rest: number }} Live counts. + */ +function countUploadRequests( page ) { + const counts = { asyncUpload: 0, rest: 0 }; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + if ( request.url().includes( '/async-upload.php' ) ) { + counts.asyncUpload++; + } else if ( + /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) ) + ) { + counts.rest++; + } + } ); + return counts; +} + +/** + * Overrides the plupload defaults the grid's uploader is built from. + * + * wp_plupload_default_settings() prints `var _wpPluploadSettings` inline + * before wp-plupload.js runs, so an accessor installed ahead of it sees the + * assignment and can adjust the defaults before any uploader reads them. + * + * @param {import('@playwright/test').Page} page + * @param {Record} defaults Defaults to override. + */ +async function overridePluploadDefaults( page, defaults ) { + await page.addInitScript( ( overrides ) => { + let settings; + Object.defineProperty( window, '_wpPluploadSettings', { + configurable: true, + get() { + return settings; + }, + set( value ) { + settings = value; + if ( value && value.defaults ) { + Object.assign( value.defaults, overrides ); + } + }, + } ); + }, defaults ); +} + +/** + * Stops the upload-media provider from ever rendering, so the store never + * receives the pipeline's settings. + * + * @param {import('@playwright/test').Page} page + */ +async function suppressProviderRender( page ) { + await page.addInitScript( () => { + let element; + window.wp = window.wp || {}; + Object.defineProperty( window.wp, 'element', { + configurable: true, + get() { + return element; + }, + set( value ) { + // The package's exports are getters, so copy before patching. + element = Object.assign( {}, value, { + createRoot( container ) { + const root = value.createRoot( container ); + const render = root.render.bind( root ); + root.render = ( node ) => { + const provider = + window.wp.uploadMedia && + window.wp.uploadMedia.MediaUploadProvider; + if ( ! node || node.type !== provider ) { + render( node ); + } + }; + return root; + }, + } ); + }, + } ); + } ); +} + +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(); + + // List mode uploads via media-new.php, so the grid integration + // script has no business on this screen either. + await expect( + page.locator( 'script[src*="media-frame-upload"]' ) + ).toHaveCount( 0 ); + + // Visiting with ?mode= persists the user's preference; restore it. + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + } ); + + test( 'uploads an image through the client-side pipeline', async ( { + page, + admin, + requestUtils, + } ) => { + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + // In Chromium builds without Document-Isolation-Policy support, + // isolation is legitimately unavailable 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( [] ); + + // The finalized attachment carries the browser-generated sub-sizes + // in its metadata (the 640x480 source is larger than thumbnail and + // medium), and the sideloaded thumbnail file really exists. + const [ attachment ] = await requestUtils.rest( { + path: '/wp/v2/media', + params: { per_page: 1 }, + } ); + const sizes = attachment.media_details.sizes || {}; + expect( Object.keys( sizes ) ).toEqual( + expect.arrayContaining( [ 'thumbnail', 'medium' ] ) + ); + + const thumbnailResponse = await page.request.get( + sizes.thumbnail.source_url + ); + expect( thumbnailResponse.status() ).toBe( 200 ); + } ); + + test( 'warns before leaving while a pipeline upload is in flight', 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' + ); + + // Hold sideload requests so the upload stays in flight at a + // deterministic point. + const heldRoutes = []; + let holding = true; + await page.route( + ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ), + async ( route ) => { + if ( holding ) { + heldRoutes.push( route ); + return; + } + await route.continue(); + } + ); + const sideloadRequested = page.waitForRequest( + ( request ) => + decodeURIComponent( request.url() ).includes( '/sideload' ), + { timeout: 60_000 } + ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + await sideloadRequested; + + // A synthetic cancelable event exercises the guard's listener + // without triggering the real (untestable) browser prompt. + const preventedWhileUploading = await page.evaluate( () => { + const event = new Event( 'beforeunload', { cancelable: true } ); + window.dispatchEvent( event ); + return event.defaultPrevented; + } ); + expect( preventedWhileUploading ).toBe( true ); + + // Release the held requests, let the upload finish, and verify the + // guard disengages once nothing is in flight anymore. + holding = false; + for ( const route of heldRoutes ) { + await route.continue(); + } + await expect( + page.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + const preventedAfterUpload = await page.evaluate( () => { + const event = new Event( 'beforeunload', { cancelable: true } ); + window.dispatchEvent( event ); + return event.defaultPrevented; + } ); + expect( preventedAfterUpload ).toBe( false ); + } ); + + 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. + const errorNotice = page + .locator( '.upload-error, .upload-errors' ) + .first(); + await expect( errorNotice ).toBeVisible( { timeout: 30_000 } ); + + // The sidebar names the file in its own span and keeps the reason + // in the message span; the reason has to be readable text, not a + // stringified object. + await expect( + errorNotice.locator( '.upload-error-filename' ) + ).toHaveText( 'disallowed.xyz' ); + const message = await errorNotice + .locator( '.upload-error-message' ) + .innerText(); + expect( message ).not.toContain( '[object Object]' ); + expect( message.trim() ).not.toBe( '' ); + } ); + test( 'falls back to the classic uploader when the page is not isolated', async ( { + page, + admin, + } ) => { + // Blocking the Document-Isolation-Policy header reproduces every + // browser that ignores it: the page is not isolated, the script + // must no-op, and classic plupload must still upload the file. + await page.route( + ( url ) => url.pathname.endsWith( '/wp-admin/upload.php' ), + async ( route ) => { + const response = await route.fetch(); + const headers = { ...response.headers() }; + delete headers[ 'document-isolation-policy' ]; + await route.fulfill( { response, headers } ); + } + ); + + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + expect( isolated ).toBe( false ); + + let asyncUploadCount = 0; + let restUploadCount = 0; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + if ( request.url().includes( '/async-upload.php' ) ) { + asyncUploadCount++; + } else if ( + /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) ) + ) { + restUploadCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + await expect( + page.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + // Classic plupload handled the upload end to end. + expect( asyncUploadCount ).toBeGreaterThanOrEqual( 1 ); + expect( restUploadCount ).toBe( 0 ); + } ); + + test( 'uploads several files at once, including duplicates', 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' + ); + + let finalizeCount = 0; + page.on( 'request', ( request ) => { + if ( + request.method() === 'POST' && + /\/wp\/v2\/media\/\d+\/finalize/.test( + decodeURIComponent( request.url() ) + ) + ) { + finalizeCount++; + } + } ); + + // The same file twice exercises the shared-identity path: both + // tiles must track progress and resolve independently. + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( [ TEST_IMAGE_PATH, TEST_IMAGE_PATH ] ); + + await expect( page.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0, + { timeout: 90_000 } + ); + await expect( + page.locator( 'li.attachment:not(.uploading)' ) + ).toHaveCount( 2 ); + expect( finalizeCount ).toBe( 2 ); + } ); + + test( 'reports pipeline progress on the tile', 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' + ); + + // Hold sideloads so the tile is observed mid-pipeline, after the + // original has been uploaded but before thumbnails have landed. + const heldRoutes = []; + let holding = true; + await page.route( + ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ), + async ( route ) => { + if ( holding ) { + heldRoutes.push( route ); + return; + } + await route.continue(); + } + ); + const sideloadRequested = page.waitForRequest( + ( request ) => + decodeURIComponent( request.url() ).includes( '/sideload' ), + { timeout: 60_000 } + ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + await sideloadRequested; + + // The placeholder tile's progress bar has advanced past zero but + // is not reported complete while work remains. + const bar = page.locator( + 'li.attachment.uploading .media-progress-bar div' + ); + await expect( bar ).toHaveAttribute( 'style', /width:\s*[1-9]/ ); + await expect( bar ).not.toHaveAttribute( 'style', /width:\s*100%/ ); + + holding = false; + for ( const route of heldRoutes ) { + await route.continue(); + } + await expect( + page.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + } ); + + test( 'surfaces a pipeline error with its message and file name', 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' + ); + + await failMediaCreate( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // The error lands in the grid's error sidebar exactly like a classic + // upload error: the file name and the server's message. + const error = page.locator( '.upload-error' ).first(); + await expect( error ).toBeVisible( { timeout: 60_000 } ); + await expect( error.locator( '.upload-error-filename' ) ).toHaveText( + 'test-image.jpg' + ); + await expect( error.locator( '.upload-error-message' ) ).toHaveText( + SIMULATED_ERROR.message + ); + + // The placeholder tile is removed rather than left spinning. + await expect( page.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0 + ); + } ); + test( 'leaves audio files to the classic uploader', 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' + ); + + let asyncUploadCount = 0; + let restUploadCount = 0; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + if ( request.url().includes( '/async-upload.php' ) ) { + asyncUploadCount++; + } else if ( + /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) ) + ) { + restUploadCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_AUDIO_PATH ); + + await expect( + page.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + expect( asyncUploadCount ).toBeGreaterThanOrEqual( 1 ); + expect( restUploadCount ).toBe( 0 ); + } ); + + test( 'leaves files to the classic uploader until the pipeline settings have landed', async ( { + page, + admin, + } ) => { + // The store is configured by rendering a provider, which delivers + // its settings asynchronously. Never rendering it reproduces a file + // added inside that window: the pipeline must not claim the file, + // because the store would hand it to a no-op uploader. + await suppressProviderRender( page ); + + 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 ready = await page.evaluate( () => + window.wp.mediaUploadPipeline.isReady() + ); + expect( ready ).toBe( false ); + + const counts = countUploadRequests( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + await expect( + page.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + expect( counts.asyncUpload ).toBeGreaterThanOrEqual( 1 ); + expect( counts.rest ).toBe( 0 ); + } ); + + test( 'hands a HEIC the browser cannot decode to the server when it can convert it', async ( { + page, + admin, + } ) => { + // No heic_upload_error means the server's image editor handles HEIC. + await overridePluploadDefaults( page, { heic_upload_error: false } ); + + 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 counts = countUploadRequests( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( UNDECODABLE_HEIC ); + + // The pipeline's decode failure is not shown; the file is re-queued + // on the classic uploader, which posts it to async-upload.php. + await expect + .poll( () => counts.asyncUpload, { timeout: 60_000 } ) + .toBeGreaterThanOrEqual( 1 ); + expect( counts.rest ).toBe( 0 ); + await expect( page.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0, + { timeout: 60_000 } + ); + await expect( page.locator( '.upload-error-message' ) ).not.toContainText( + 'HEIC' + ); + } ); + + test( 'reports the HEIC error when neither the browser nor the server can convert it', async ( { + page, + admin, + } ) => { + await overridePluploadDefaults( page, { heic_upload_error: true } ); + + 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 counts = countUploadRequests( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( UNDECODABLE_HEIC ); + + // Nothing can convert the file, so the pipeline's message (which + // explains the platform limitation) is what the user sees. + const error = page.locator( '.upload-error' ).first(); + await expect( error ).toBeVisible( { timeout: 60_000 } ); + await expect( error.locator( '.upload-error-filename' ) ).toHaveText( + UNDECODABLE_HEIC.name + ); + await expect( error.locator( '.upload-error-message' ) ).toContainText( + 'HEIC' + ); + expect( counts.asyncUpload ).toBe( 0 ); + expect( counts.rest ).toBe( 0 ); + await expect( page.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0 + ); + } ); +} ); diff --git a/tests/e2e/specs/media-modal-client-side-upload.test.js b/tests/e2e/specs/media-modal-client-side-upload.test.js new file mode 100644 index 0000000000000..99f6e68db5671 --- /dev/null +++ b/tests/e2e/specs/media-modal-client-side-upload.test.js @@ -0,0 +1,206 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * External dependencies + */ +import path from 'path'; + +// A 640x480 image: it must be larger than at least one registered sub-size +// (thumbnail, medium) so the pipeline generates and sideloads thumbnails. +const TEST_IMAGE_PATH = path.join( __dirname, '../assets/test-image.jpg' ); + +// The plupload HTML5 runtime creates this hidden file input over the modal's +// "Select Files" button; setting files on it triggers FilesAdded, the same +// event a file dropped on the modal fires. +const FILE_INPUT_SELECTOR = '.media-modal .moxie-shim-html5 input[type="file"]'; + +/** + * Counts the POST requests that upload a file, per transport. + * + * 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. + * + * @param {import('@playwright/test').Page} page + * @return {{ asyncUpload: number, create: number, sideload: number, finalize: number }} Live counts. + */ +function countUploadRequests( page ) { + const counts = { asyncUpload: 0, create: 0, sideload: 0, finalize: 0 }; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + const url = request.url(); + if ( url.includes( '/async-upload.php' ) ) { + counts.asyncUpload++; + return; + } + const decoded = decodeURIComponent( url ); + if ( /\/wp\/v2\/media\/\d+\/sideload/.test( decoded ) ) { + counts.sideload++; + } else if ( /\/wp\/v2\/media\/\d+\/finalize/.test( decoded ) ) { + counts.finalize++; + } else if ( /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) ) { + counts.create++; + } + } ); + return counts; +} + +/** + * Opens the media modal from a new Image block and switches to its upload tab. + * + * @param {import('@playwright/test').Page} page + * @param {import('@wordpress/e2e-test-utils-playwright').Editor} editor + * @return {Promise} The modal. + */ +async function openModalFromImageBlock( page, editor ) { + await editor.insertBlock( { name: 'core/image' } ); + await editor.canvas.getByRole( 'button', { name: 'Media Library' } ).click(); + + const modal = page.locator( '.media-modal' ); + await expect( modal ).toBeVisible(); + await modal.locator( '.media-router' ).getByText( 'Upload files' ).click(); + + return modal; +} + +test.describe( 'Media modal client-side uploads', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test( 'loads the media frame integration in the block editor', async ( { + page, + } ) => { + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + await expect( + page.locator( 'script[src*="media-frame-upload"]' ) + ).toHaveCount( 1 ); + } ); + + test( 'uploads an image from the media modal through the client-side pipeline', async ( { + page, + editor, + requestUtils, + } ) => { + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + // In Chromium builds without Document-Isolation-Policy support, + // isolation is legitimately unavailable 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' + ); + + const counts = countUploadRequests( page ); + const modal = await openModalFromImageBlock( page, editor ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached' } ); + + // A settled tile is not on its own proof that this upload finished: + // the modal leaves the upload tab for the library grid as soon as the + // file is queued, so anything already in the library renders one + // straight away. Wait for the pipeline's own last request instead. + const finalized = page.waitForRequest( + ( request ) => + request.method() === 'POST' && + /\/wp\/v2\/media\/\d+\/finalize/.test( + decodeURIComponent( request.url() ) + ), + { timeout: 60_000 } + ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + await finalized; + + await expect( modal.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0, + { timeout: 60_000 } + ); + + // The original upload and every sub-size go through the REST API, + // the upload is finalized exactly once, and nothing goes through the + // classic async-upload.php endpoint. + expect( counts.create ).toBeGreaterThanOrEqual( 1 ); + expect( counts.sideload ).toBeGreaterThanOrEqual( 1 ); + expect( counts.finalize ).toBe( 1 ); + expect( counts.asyncUpload ).toBe( 0 ); + + // The attachment carries the browser-generated sub-sizes. + const [ attachment ] = await requestUtils.rest( { + path: '/wp/v2/media', + params: { per_page: 1 }, + } ); + expect( Object.keys( attachment.media_details.sizes || {} ) ).toEqual( + expect.arrayContaining( [ 'thumbnail', 'medium' ] ) + ); + + // The modal still hands the finished upload back to the block. + await modal + .getByRole( 'button', { name: 'Select', exact: true } ) + .click(); + await expect( + editor.canvas.locator( 'figure.wp-block-image img' ) + ).toHaveAttribute( 'src', attachment.source_url ); + } ); + + test( 'falls back to the classic uploader when the editor is not isolated', async ( { + page, + editor, + admin, + } ) => { + // Blocking the Document-Isolation-Policy header reproduces every + // browser that ignores it: the page is not isolated, the script + // must no-op, and classic plupload must still upload the file. + await page.route( + ( url ) => url.pathname.endsWith( '/wp-admin/post-new.php' ), + async ( route ) => { + const response = await route.fetch(); + const headers = { ...response.headers() }; + delete headers[ 'document-isolation-policy' ]; + await route.fulfill( { response, headers } ); + } + ); + await admin.createNewPost(); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + expect( isolated ).toBe( false ); + + const counts = countUploadRequests( page ); + const modal = await openModalFromImageBlock( page, editor ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + await expect( modal.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0, + { timeout: 60_000 } + ); + await expect( + modal.locator( 'li.attachment' ).first() + ).toBeVisible(); + + // Classic plupload handled the upload end to end. + expect( counts.asyncUpload ).toBeGreaterThanOrEqual( 1 ); + expect( counts.create + counts.sideload + counts.finalize ).toBe( 0 ); + } ); +} ); diff --git a/tests/e2e/specs/media-new-client-side-upload.test.js b/tests/e2e/specs/media-new-client-side-upload.test.js new file mode 100644 index 0000000000000..960b96942531b --- /dev/null +++ b/tests/e2e/specs/media-new-client-side-upload.test.js @@ -0,0 +1,823 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * External dependencies + */ +import path from 'path'; + +// A 640x480 image: it must be larger than at least one registered sub-size +// (thumbnail, medium) so the pipeline generates and sideloads thumbnails. +const TEST_IMAGE_PATH = path.join( __dirname, '../assets/test-image.jpg' ); + +// A short WAV file: audio stays on the classic upload path so it keeps the +// ID3-derived title and description that media_handle_upload() sets. +const TEST_AUDIO_PATH = path.join( __dirname, '../assets/test-audio.wav' ); + +// The plupload HTML5 runtime creates this hidden file input over the +// "Select Files" browse button; setting files on it triggers FilesAdded. +const FILE_INPUT_SELECTOR = '.moxie-shim-html5 input[type="file"]'; + +// A REST error the pipeline surfaces as-is: its message matches none of the +// transient patterns @wordpress/upload-media retries on. +const SIMULATED_ERROR = { + code: 'rest_upload_simulated_failure', + message: 'Simulated server failure for testing', + data: { status: 500 }, +}; + +/** + * Fails every REST media create request (not sideload/finalize). + * + * @param {import('@playwright/test').Page} page + */ +async function failMediaCreate( page ) { + await page.route( + ( url ) => { + const decoded = decodeURIComponent( url.href ); + return ( + /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) && + ! /\/(sideload|finalize)/.test( decoded ) + ); + }, + async ( route ) => { + if ( route.request().method() !== 'POST' ) { + await route.continue(); + return; + } + await route.fulfill( { + status: 500, + contentType: 'application/json', + body: JSON.stringify( SIMULATED_ERROR ), + } ); + } + ); +} + +// A file plupload and the store both treat as HEIC but no browser can +// decode: whether the server can convert it is what decides its path. +const UNDECODABLE_HEIC = { + name: 'undecodable.heic', + mimeType: 'image/heic', + buffer: Buffer.from( 'not a HEIC image' ), +}; + +/** + * Adjusts the settings plupload-handlers.js builds the uploader from. + * + * media_upload_form() assigns `wpUploaderInit` onto window inline before + * the footer scripts run, so an accessor installed ahead of it sees the + * assignment and can adjust the settings the way a plugin script would. + * + * @param {import('@playwright/test').Page} page + * @param {( settings: Object ) => void} adjust Called with the settings. + */ +async function overrideUploaderInit( page, adjust ) { + // The adjustment runs in the page, so its source is inlined into the + // init script rather than passed as (non-serializable) data. + await page.addInitScript( { + content: `( function ( adjust ) { + let settings; + Object.defineProperty( window, 'wpUploaderInit', { + configurable: true, + get() { + return settings; + }, + set( value ) { + settings = value; + if ( value ) { + adjust( value ); + } + }, + } ); + } )( ${ adjust.toString() } );`, + } ); +} + +test.describe( 'Add New Media File client-side uploads', () => { + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + await requestUtils.deleteAllPosts(); + } ); + + test( 'sends the Document-Isolation-Policy header', async ( { + page, + admin, + } ) => { + const responsePromise = page.waitForResponse( + ( resp ) => + resp.url().includes( '/wp-admin/media-new.php' ) && + resp.request().resourceType() === 'document' && + resp.status() === 200 + ); + + await admin.visitAdminPage( 'media-new.php' ); + + const headers = ( await responsePromise ).headers(); + expect( headers[ 'document-isolation-policy' ] ).toBe( + 'isolate-and-credentialless' + ); + } ); + + test( 'uploads an image through the client-side pipeline', async ( { + page, + admin, + requestUtils, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + // In Chromium builds without Document-Isolation-Policy support, + // isolation is legitimately unavailable 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' ) ) { + // The pipeline still POSTs to async-upload.php once per + // upload to fetch the finished item markup (fetch=3, no file + // payload); only file uploads must not go through it. + const postData = request.postData() || ''; + if ( ! /(^|&)fetch=/.test( postData ) ) { + 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 finished attachment row renders with the Edit link fetched + // from the async-upload.php markup endpoint. + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).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 ); + + // No file upload goes through the classic async-upload.php endpoint. + expect( asyncUploads ).toEqual( [] ); + + // The finalized attachment carries the browser-generated sub-sizes + // in its metadata (the 640x480 source is larger than thumbnail and + // medium), and the sideloaded thumbnail file really exists. + const [ attachment ] = await requestUtils.rest( { + path: '/wp/v2/media', + params: { per_page: 1 }, + } ); + const sizes = attachment.media_details.sizes || {}; + expect( Object.keys( sizes ) ).toEqual( + expect.arrayContaining( [ 'thumbnail', 'medium' ] ) + ); + + const thumbnailResponse = await page.request.get( + sizes.thumbnail.source_url + ); + expect( thumbnailResponse.status() ).toBe( 200 ); + } ); + + test( 'warns before leaving while a pipeline upload is in flight', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + // Hold sideload requests so the upload stays in flight at a + // deterministic point. + const heldRoutes = []; + let holding = true; + await page.route( + ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ), + async ( route ) => { + if ( holding ) { + heldRoutes.push( route ); + return; + } + await route.continue(); + } + ); + const sideloadRequested = page.waitForRequest( + ( request ) => + decodeURIComponent( request.url() ).includes( '/sideload' ), + { timeout: 60_000 } + ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + await sideloadRequested; + + // A synthetic cancelable event exercises the guard's listener + // without triggering the real (untestable) browser prompt. + const preventedWhileUploading = await page.evaluate( () => { + const event = new Event( 'beforeunload', { cancelable: true } ); + window.dispatchEvent( event ); + return event.defaultPrevented; + } ); + expect( preventedWhileUploading ).toBe( true ); + + // Release the held requests, let the upload finish, and verify the + // guard disengages once nothing is in flight anymore. + holding = false; + for ( const route of heldRoutes ) { + await route.continue(); + } + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + const preventedAfterUpload = await page.evaluate( () => { + const event = new Event( 'beforeunload', { cancelable: true } ); + window.dispatchEvent( event ); + return event.defaultPrevented; + } ); + expect( preventedAfterUpload ).toBe( false ); + } ); + + test( 'shows an error for a disallowed file type', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + 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 error surfaces either as a pipeline per-item error + // (itemAjaxError renders .error-div inside the media item) or as a + // plupload extension rejection (a .media-item.error element), + // depending on which layer rejects the file first. + const errorItem = page + .locator( '.media-item .error-div, .media-item.error' ) + .first(); + await expect( errorItem ).toBeVisible( { timeout: 30_000 } ); + + // The reason has to be readable: getErrorMessage() returns an object, + // so handing it straight to the UI renders "[object Object]". + const errorText = await errorItem.innerText(); + expect( errorText ).not.toContain( '[object Object]' ); + } ); + + test( 'attaches the upload to the post named by post_id', async ( { + page, + admin, + requestUtils, + } ) => { + // Published, so the attachment (post_status 'inherit') stays visible + // in the media collection. + const post = await requestUtils.createPost( { + title: 'Client-side upload parent', + status: 'publish', + } ); + + await admin.visitAdminPage( 'media-new.php', `post_id=${ post.id }` ); + + 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( TEST_IMAGE_PATH ); + + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + // The classic flow posts `post_id` to async-upload.php; the pipeline + // has to send the REST equivalent or the file lands unattached even + // though the screen counts it against the post. + const [ attachment ] = await requestUtils.rest( { + path: '/wp/v2/media', + params: { per_page: 1 }, + } ); + expect( attachment.post ).toBe( post.id ); + } ); + test( 'falls back to the classic uploader when the page is not isolated', async ( { + page, + admin, + } ) => { + // Blocking the Document-Isolation-Policy header reproduces every + // browser that ignores it: the page is not isolated, the script + // must no-op, and classic plupload must still upload the file. + await page.route( + ( url ) => url.pathname.endsWith( '/wp-admin/media-new.php' ), + async ( route ) => { + const response = await route.fetch(); + const headers = { ...response.headers() }; + delete headers[ 'document-isolation-policy' ]; + await route.fulfill( { response, headers } ); + } + ); + + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + expect( isolated ).toBe( false ); + + let asyncFileUploads = 0; + let restUploadCount = 0; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + if ( request.url().includes( '/async-upload.php' ) ) { + // Ignore the markup fetch (fetch=3); count file uploads. + if ( ! /(^|&)fetch=/.test( request.postData() || '' ) ) { + asyncFileUploads++; + } + } else if ( + /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) ) + ) { + restUploadCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + expect( asyncFileUploads ).toBeGreaterThanOrEqual( 1 ); + expect( restUploadCount ).toBe( 0 ); + } ); + + test( 'uploads several files at once, including duplicates', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + let finalizeCount = 0; + page.on( 'request', ( request ) => { + if ( + request.method() === 'POST' && + /\/wp\/v2\/media\/\d+\/finalize/.test( + decodeURIComponent( request.url() ) + ) + ) { + finalizeCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( [ TEST_IMAGE_PATH, TEST_IMAGE_PATH ] ); + + // Each finished row carries the Edit link (the markup may repeat + // the class inside a row, so count rows rather than links). + await expect( + page.locator( '#media-items .media-item:has(.edit-attachment)' ) + ).toHaveCount( 2, { timeout: 90_000 } ); + expect( finalizeCount ).toBe( 2 ); + } ); + + test( 'reports pipeline progress on the item', async ( { page, admin } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + const heldRoutes = []; + let holding = true; + await page.route( + ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ), + async ( route ) => { + if ( holding ) { + heldRoutes.push( route ); + return; + } + await route.continue(); + } + ); + const sideloadRequested = page.waitForRequest( + ( request ) => + decodeURIComponent( request.url() ).includes( '/sideload' ), + { timeout: 60_000 } + ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + await sideloadRequested; + + // The screen's own progress markup (from fileQueued) reflects the + // pipeline: past zero, not yet complete. + const item = page.locator( '#media-items .media-item' ).first(); + await expect( item.locator( '.percent' ) ).toHaveText( /^[1-9]\d?%$/ ); + await expect( item.locator( '.bar' ) ).toHaveAttribute( + 'style', + /width:\s*[1-9]/ + ); + + holding = false; + for ( const route of heldRoutes ) { + await route.continue(); + } + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + } ); + + test( 'surfaces a pipeline error with the server message', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + await failMediaCreate( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // itemAjaxError() renders the message inside the item, alongside + // the file name, exactly as a classic upload error would. + const item = page.locator( '#media-items .media-item' ).first(); + await expect( item.locator( '.error-div' ) ).toBeVisible( { + timeout: 60_000, + } ); + await expect( item.locator( '.error-div' ) ).toContainText( + SIMULATED_ERROR.message + ); + await expect( item.locator( '.error-div' ) ).toContainText( + 'test-image.jpg' + ); + } ); + test( 'uploads unattached when post_id is not a valid post', async ( { + page, + admin, + requestUtils, + } ) => { + // media_upload_form() hands plupload the raw post_id, but the screen + // validates it and prints the validated value; async-upload.php + // silently attaches to nothing, so the pipeline must do the same + // instead of sending the REST API a parent it will reject. + await admin.visitAdminPage( 'media-new.php', 'post_id=999999999' ); + + 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( TEST_IMAGE_PATH ); + + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + await expect( page.locator( '.media-item .error-div' ) ).toHaveCount( + 0 + ); + + const [ attachment ] = await requestUtils.rest( { + path: '/wp/v2/media', + params: { per_page: 1 }, + } ); + expect( attachment.post ).toBeNull(); + } ); + + test( 'forwards plupload multipart params to the REST upload', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + // A plugin adding a field through plupload_default_params or + // uploader.settings.multipart_params reads it back from $_POST on + // the classic path; the REST request must carry it the same way. + // The browser does not expose multipart bodies that carry a file, + // so record the FormData handed to fetch() from inside the page. + await page.evaluate( () => { + window.uploader.settings.multipart_params.e2e_custom_param = + 'forwarded'; + + window.__e2eCreateBodies = []; + const originalFetch = window.fetch; + window.fetch = function ( input, init ) { + const url = typeof input === 'string' ? input : input.url; + if ( + init && + init.body instanceof FormData && + /\/wp\/v2\/media(?:[?&]|$)/.test( decodeURIComponent( url ) ) + ) { + const fields = {}; + init.body.forEach( ( value, key ) => { + fields[ key ] = + value instanceof Blob ? '[file]' : String( value ); + } ); + window.__e2eCreateBodies.push( fields ); + } + return originalFetch.apply( this, arguments ); + }; + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + const bodies = await page.evaluate( () => window.__e2eCreateBodies ); + expect( bodies.length ).toBeGreaterThanOrEqual( 1 ); + const fields = bodies[ 0 ]; + expect( fields.e2e_custom_param ).toBe( 'forwarded' ); + expect( fields.file ).toBe( '[file]' ); + // The classic transport's own fields stay behind. + expect( fields ).not.toHaveProperty( '_wpnonce' ); + expect( fields ).not.toHaveProperty( 'action' ); + } ); + + test( 'leaves audio files to the classic uploader', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + let asyncFileUploads = 0; + let restUploadCount = 0; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + if ( request.url().includes( '/async-upload.php' ) ) { + if ( ! /(^|&)fetch=/.test( request.postData() || '' ) ) { + asyncFileUploads++; + } + } else if ( + /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) ) + ) { + restUploadCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_AUDIO_PATH ); + + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + expect( asyncFileUploads ).toBeGreaterThanOrEqual( 1 ); + expect( restUploadCount ).toBe( 0 ); + } ); + + test( 'renders a failed upload with an accessible Dismiss button', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + await failMediaCreate( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // Same markup as a server-side failure from async-upload.php: a + // real button, described by the notice it dismisses. + const notice = page.locator( '.media-item .error-div' ).first(); + await expect( notice ).toBeVisible( { timeout: 60_000 } ); + const dismiss = notice.getByRole( 'button', { name: 'Dismiss' } ); + await expect( dismiss ).toBeVisible(); + await expect( dismiss ).toHaveAttribute( + 'aria-describedby', + await notice.getAttribute( 'id' ) + ); + await expect( notice ).toContainText( + '“test-image.jpg” has failed to upload.' + ); + + // Dismissing removes the item and returns focus to the browse button. + await dismiss.click(); + await expect( notice ).toHaveCount( 0 ); + await expect( page.locator( '#plupload-browse-button' ) ).toBeFocused(); + } ); + + test( 'hands a HEIC the browser cannot decode to the server when it can convert it', async ( { + page, + admin, + } ) => { + // No heic_upload_error means the server's image editor handles HEIC. + await overrideUploaderInit( page, ( settings ) => { + settings.heic_upload_error = false; + } ); + + await admin.visitAdminPage( 'media-new.php' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + let asyncFileUploads = 0; + let restUploadCount = 0; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + if ( request.url().includes( '/async-upload.php' ) ) { + if ( ! /(^|&)fetch=/.test( request.postData() || '' ) ) { + asyncFileUploads++; + } + } else if ( + /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) ) + ) { + restUploadCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( UNDECODABLE_HEIC ); + + // The pipeline's decode failure is not shown; the file is re-queued + // on the classic uploader, which posts it to async-upload.php. + await expect + .poll( () => asyncFileUploads, { timeout: 60_000 } ) + .toBeGreaterThanOrEqual( 1 ); + expect( restUploadCount ).toBe( 0 ); + + // Exactly one item is left for the file: the pipeline's progress item + // was dropped when the classic uploader took over. + await expect( + page.locator( '#media-items .media-item .error-div' ) + ).not.toContainText( 'HEIC' ); + await expect( + page.locator( '#media-items .media-item .progress' ) + ).toHaveCount( 0, { timeout: 60_000 } ); + await expect( page.locator( '#media-items .media-item' ) ).toHaveCount( + 1 + ); + } ); + + test( 'keeps a PostInit handler another script placed in wpUploaderInit', async ( { + page, + admin, + } ) => { + // A plugin that adds its own handler map before the uploader is + // created must still see its PostInit run once the pipeline hooks in. + await overrideUploaderInit( page, ( settings ) => { + settings.init = { + PostInit() { + window.__pluginPostInitCalls = + ( window.__pluginPostInitCalls || 0 ) + 1; + }, + }; + } ); + + await admin.visitAdminPage( 'media-new.php' ); + + 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 expect + .poll( () => page.evaluate( () => window.__pluginPostInitCalls ) ) + .toBe( 1 ); + + let restUploadCount = 0; + page.on( 'request', ( request ) => { + if ( + request.method() === 'POST' && + /\/wp\/v2\/media(?:[?&]|$)/.test( + decodeURIComponent( request.url() ) + ) + ) { + restUploadCount++; + } + } ); + + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // The pipeline's own hook still intercepted the upload. + await expect( + page.locator( '#media-items .media-item .edit-attachment' ).first() + ).toBeVisible( { timeout: 60_000 } ); + expect( restUploadCount ).toBe( 1 ); + } ); +} ); diff --git a/tests/phpunit/tests/media/wpEnqueueMediaFrameUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaFrameUpload.php new file mode 100644 index 0000000000000..b4d94646cfa65 --- /dev/null +++ b/tests/phpunit/tests/media/wpEnqueueMediaFrameUpload.php @@ -0,0 +1,309 @@ +original_http_host = $_SERVER['HTTP_HOST'] ?? null; + $this->original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + + // A secure origin so client-side media processing is enabled. + $_SERVER['HTTP_HOST'] = 'localhost'; + + /* + * Cross-origin isolation relies on Document-Isolation-Policy, so the + * enqueue is gated on Chromium 137+. The PHPUnit bootstrap defines no + * User-Agent at all, which reads as "not Chromium". + */ + $_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'; + + /* + * 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(); + + // The screen was cross-origin isolated, as wp_set_up_cross_origin_isolation() records. + $GLOBALS['_wp_cross_origin_isolated'] = true; + } + + public function tear_down() { + if ( null === $this->original_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $this->original_http_host; + } + + if ( null === $this->original_user_agent ) { + unset( $_SERVER['HTTP_USER_AGENT'] ); + } else { + $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent; + } + + $GLOBALS['wp_scripts'] = $this->original_wp_scripts; + $GLOBALS['current_screen'] = null; + unset( $GLOBALS['_wp_cross_origin_isolated'] ); + + remove_all_filters( 'wp_client_side_media_processing_enabled' ); + parent::tear_down(); + } + + /** + * @ticket 65661 + */ + public function test_script_enqueued() { + wp_enqueue_media_frame_upload(); + + $this->assertTrue( wp_script_is( 'media-frame-upload', 'enqueued' ) ); + } + + /** + * A screen wp_set_up_cross_origin_isolation() did not isolate cannot run + * the pipeline, so the media frame there keeps its classic uploads and + * does not download the pipeline bundles. + * + * @ticket 65661 + */ + public function test_script_not_enqueued_when_request_not_isolated() { + unset( $GLOBALS['_wp_cross_origin_isolated'] ); + + $this->assertFalse( wp_is_cross_origin_isolated_request() ); + + wp_enqueue_media_frame_upload(); + + $this->assertFalse( wp_script_is( 'media-frame-upload', 'enqueued' ) ); + } + + /** + * The block editor, the site editor and the block widgets screen load the + * media modal through wp_enqueue_media(), so that is where the frame + * integration is enqueued on an isolated screen. + * + * @ticket 65661 + */ + public function test_wp_enqueue_media_enqueues_script_on_isolated_screen() { + set_current_screen( 'post' ); + + wp_enqueue_media(); + + $this->assertTrue( wp_script_is( 'media-frame-upload', 'enqueued' ) ); + $this->assertTrue( wp_script_is( 'media-editor', 'enqueued' ) ); + } + + /** + * @ticket 65661 + */ + public function test_wp_enqueue_media_skips_script_when_request_not_isolated() { + unset( $GLOBALS['_wp_cross_origin_isolated'] ); + set_current_screen( 'options-general' ); + + wp_enqueue_media(); + + $this->assertFalse( wp_script_is( 'media-frame-upload', 'enqueued' ) ); + $this->assertTrue( wp_script_is( 'media-editor', '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_frame_upload(); + + $this->assertFalse( wp_script_is( 'media-frame-upload', 'enqueued' ) ); + } + + /** + * Document-Isolation-Policy is Chromium-only, so a browser that can never + * be cross-origin isolated must not download the pipeline bundles for a + * script that could only no-op. + * + * @ticket 65661 + */ + public function test_script_not_enqueued_for_non_chromium_user_agent() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0'; + + wp_enqueue_media_frame_upload(); + + $this->assertFalse( wp_script_is( 'media-frame-upload', 'enqueued' ) ); + } + + /** + * @ticket 65661 + */ + public function test_script_not_enqueued_for_older_chromium() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'; + + wp_enqueue_media_frame_upload(); + + $this->assertFalse( wp_script_is( 'media-frame-upload', 'enqueued' ) ); + } + + /** + * The script depends on media-views and the shared pipeline glue, 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_frame_upload(); + + $script = wp_scripts()->registered['media-frame-upload']; + $this->assertContains( 'media-views', $script->deps ); + $this->assertContains( 'media-upload-pipeline', $script->deps ); + $this->assertNotContains( 'wp-block-editor', $script->deps ); + + // The pipeline packages are pulled in through the shared glue script. + $pipeline = wp_scripts()->registered['media-upload-pipeline']; + $this->assertContains( 'wp-upload-media', $pipeline->deps ); + $this->assertContains( 'wp-media-utils', $pipeline->deps ); + $this->assertNotContains( 'wp-block-editor', $pipeline->deps ); + $this->assertNotContains( 'media-views', $pipeline->deps ); + } + + /** + * @ticket 65661 + */ + public function test_inline_settings_expose_all_keys() { + wp_enqueue_media_frame_upload(); + + $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' ); + $inline = implode( "\n", (array) $before ); + + $this->assertStringContainsString( 'window._wpMediaUploadPipelineSettings', $inline ); + + foreach ( array( + 'maxUploadFileSize', + 'allowedMimeTypes', + 'allImageSizes', + 'bigImageSizeThreshold', + 'imageStripMeta', + 'imageMaxBitDepth', + ) as $key ) { + $this->assertStringContainsString( $key, $inline ); + } + } + + /** + * The inline settings must be exactly the JSON encoding of + * wp_get_media_library_upload_settings(), so the script consumes the + * same values the server computes. + * + * @ticket 65661 + */ + public function test_inline_settings_match_upload_settings() { + wp_enqueue_media_frame_upload(); + + $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' ); + $inline = implode( "\n", (array) $before ); + + $this->assertStringContainsString( + wp_json_encode( wp_get_media_library_upload_settings() ), + $inline + ); + } + + /** + * @ticket 65661 + */ + public function test_allowed_mime_types_respect_upload_mimes_filter() { + add_filter( + 'upload_mimes', + static function ( $mimes ) { + unset( $mimes['gif'] ); + return $mimes; + } + ); + + $settings = wp_get_media_library_upload_settings(); + + $this->assertArrayNotHasKey( 'gif', $settings['allowedMimeTypes'] ); + } + + /** + * @ticket 65661 + */ + public function test_image_strip_meta_filter() { + add_filter( 'image_strip_meta', '__return_false' ); + + $settings = wp_get_media_library_upload_settings(); + + $this->assertFalse( $settings['imageStripMeta'] ); + } + + /** + * @ticket 65661 + */ + public function test_image_max_bit_depth_filter() { + add_filter( + 'image_max_bit_depth', + static function () { + return 8; + } + ); + + $settings = wp_get_media_library_upload_settings(); + + $this->assertSame( 8, $settings['imageMaxBitDepth'] ); + } + + /** + * @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/wpEnqueueMediaNewUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php new file mode 100644 index 0000000000000..1c14fbf212c48 --- /dev/null +++ b/tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php @@ -0,0 +1,188 @@ +original_http_host = $_SERVER['HTTP_HOST'] ?? null; + $this->original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + + // A secure origin so client-side media processing is enabled. + $_SERVER['HTTP_HOST'] = 'localhost'; + + /* + * Cross-origin isolation relies on Document-Isolation-Policy, so the + * enqueue is gated on Chromium 137+. The PHPUnit bootstrap defines no + * User-Agent at all, which reads as "not Chromium". + */ + $_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'; + + /* + * 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( 'media' ); + $this->original_wp_scripts = $GLOBALS['wp_scripts'] ?? null; + $GLOBALS['wp_scripts'] = new WP_Scripts(); + + // The screen was cross-origin isolated, as wp_set_up_cross_origin_isolation() records. + $GLOBALS['_wp_cross_origin_isolated'] = true; + } + + public function tear_down() { + if ( null === $this->original_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $this->original_http_host; + } + + if ( null === $this->original_user_agent ) { + unset( $_SERVER['HTTP_USER_AGENT'] ); + } else { + $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent; + } + + $GLOBALS['wp_scripts'] = $this->original_wp_scripts; + $GLOBALS['current_screen'] = null; + unset( $GLOBALS['_wp_cross_origin_isolated'] ); + + remove_all_filters( 'wp_client_side_media_processing_enabled' ); + parent::tear_down(); + } + + /** + * @ticket 65662 + */ + public function test_script_enqueued() { + wp_enqueue_media_new_upload(); + + $this->assertTrue( wp_script_is( 'media-new-upload', 'enqueued' ) ); + } + + /** + * @ticket 65662 + */ + public function test_script_not_enqueued_when_client_side_processing_disabled() { + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + wp_enqueue_media_new_upload(); + + $this->assertFalse( wp_script_is( 'media-new-upload', 'enqueued' ) ); + } + + /** + * Document-Isolation-Policy is Chromium-only, so a browser that can never + * be cross-origin isolated must not download the pipeline bundles for a + * script that could only no-op. + * + * @ticket 65662 + */ + public function test_script_not_enqueued_for_non_chromium_user_agent() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0'; + + wp_enqueue_media_new_upload(); + + $this->assertFalse( wp_script_is( 'media-new-upload', 'enqueued' ) ); + } + + /** + * @ticket 65662 + */ + public function test_script_not_enqueued_for_older_chromium() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'; + + wp_enqueue_media_new_upload(); + + $this->assertFalse( wp_script_is( 'media-new-upload', 'enqueued' ) ); + } + + /** + * The script depends on plupload-handlers (whose UI helpers it reuses) + * and the shared pipeline glue, not on media-views or wp-block-editor, + * so the heavy Media Library and block editor bundles are not dragged + * onto the "Add New Media File" screen. + * + * @ticket 65662 + */ + public function test_dependencies() { + wp_enqueue_media_new_upload(); + + $script = wp_scripts()->registered['media-new-upload']; + $this->assertContains( 'plupload-handlers', $script->deps ); + $this->assertContains( 'media-upload-pipeline', $script->deps ); + $this->assertNotContains( 'wp-block-editor', $script->deps ); + + // The pipeline packages are pulled in through the shared glue script. + $pipeline = wp_scripts()->registered['media-upload-pipeline']; + $this->assertContains( 'wp-upload-media', $pipeline->deps ); + $this->assertContains( 'wp-media-utils', $pipeline->deps ); + $this->assertNotContains( 'wp-block-editor', $pipeline->deps ); + $this->assertNotContains( 'media-views', $pipeline->deps ); + } + + /** + * @ticket 65662 + */ + public function test_inline_settings_expose_all_keys() { + wp_enqueue_media_new_upload(); + + $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' ); + $inline = implode( "\n", (array) $before ); + + $this->assertStringContainsString( 'window._wpMediaUploadPipelineSettings', $inline ); + + foreach ( array( + 'maxUploadFileSize', + 'allowedMimeTypes', + 'allImageSizes', + 'bigImageSizeThreshold', + 'imageStripMeta', + 'imageMaxBitDepth', + ) as $key ) { + $this->assertStringContainsString( $key, $inline ); + } + } + + /** + * The inline settings must be exactly the JSON encoding of + * wp_get_media_library_upload_settings(), the same settings source + * the grid integration uses. + * + * @ticket 65662 + */ + public function test_inline_settings_match_upload_settings() { + wp_enqueue_media_new_upload(); + + $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' ); + $inline = implode( "\n", (array) $before ); + + $this->assertStringContainsString( + wp_json_encode( wp_get_media_library_upload_settings() ), + $inline + ); + } +} diff --git a/tests/phpunit/tests/media/wpIsDocumentIsolationPolicySupported.php b/tests/phpunit/tests/media/wpIsDocumentIsolationPolicySupported.php new file mode 100644 index 0000000000000..8765ff1290ce8 --- /dev/null +++ b/tests/phpunit/tests/media/wpIsDocumentIsolationPolicySupported.php @@ -0,0 +1,61 @@ +original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? 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; + } + parent::tear_down(); + } + + /** + * @ticket 65661 + * + * @dataProvider data_user_agents + * + * @param string|null $user_agent The User-Agent header, or null for none. + * @param bool $expected Whether Document-Isolation-Policy is supported. + */ + public function test_user_agent_support( ?string $user_agent, bool $expected ) { + if ( null === $user_agent ) { + unset( $_SERVER['HTTP_USER_AGENT'] ); + } else { + $_SERVER['HTTP_USER_AGENT'] = $user_agent; + } + + $this->assertSame( $expected, wp_is_document_isolation_policy_supported() ); + } + + /** + * @return array[] + */ + public function data_user_agents() { + return array( + 'no user agent' => array( null, false ), + 'Firefox' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0', false ), + 'Safari' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', false ), + 'Chrome 136' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', false ), + 'Chrome 137' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', true ), + 'Edge 140' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0', true ), + ); + } +} diff --git a/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php new file mode 100644 index 0000000000000..c1e5bbdd1caf2 --- /dev/null +++ b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php @@ -0,0 +1,319 @@ +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' ); + unset( $GLOBALS['current_screen'] ); + 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' ) ) ); + } + + /** + * The isolation callback must be wired to the screen's load hook in + * default-filters.php: the buffer has to start before upload.php + * produces any output, and none of the gating below runs at all if + * the hook is missing. + * + * @ticket 65661 + */ + public function test_hooked_to_load_upload() { + $this->assertSame( 10, has_action( 'load-upload.php', 'wp_set_up_cross_origin_isolation' ) ); + } + + /** + * @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() ); + } + + /** + * upload.php only renders grid mode for the exact saved value 'grid' and + * renders list mode for any other truthy value, so an unknown saved value + * must resolve to 'list'. Otherwise a page that upload.php renders in list + * mode - and that has no client-side pipeline - would be cross-origin + * isolated. + * + * @ticket 65661 + */ + public function test_unknown_user_option_mode_resolves_to_list() { + 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', 'cards' ); + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + /** + * upload.php treats any falsey saved value as unset and renders grid + * mode, so a saved '0' must resolve to grid here too. + * + * @ticket 65661 + */ + public function test_falsey_user_option_mode_falls_back_to_grid() { + 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', '0' ); + + $this->assertSame( 'grid', wp_get_media_library_mode() ); + } + + /** + * upload.php renders list mode for a truthy saved value that is not the + * string 'grid'. Scalars come back from user meta as strings, so the only + * non-string a plugin can store is an array or object, and that must + * resolve to list rather than grid here. + * + * @ticket 65661 + */ + public function test_non_string_user_option_mode_resolves_to_list() { + 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', array( 'grid' ) ); + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + /** + * @ticket 65661 + */ + public function test_no_buffer_for_unknown_user_option_mode() { + $this->set_up_grid_isolation_environment(); + unset( $_GET['mode'] ); + + update_user_option( get_current_user_id(), 'media_library_mode', 'cards' ); + + $level_before = ob_get_level(); + set_current_screen( 'upload' ); + wp_set_up_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * @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(); + set_current_screen( 'upload' ); + wp_set_up_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(); + set_current_screen( 'upload' ); + wp_set_up_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(); + set_current_screen( 'upload' ); + wp_set_up_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(); + set_current_screen( 'upload' ); + wp_set_up_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(); + set_current_screen( 'upload' ); + wp_set_up_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(); + set_current_screen( 'upload' ); + wp_set_up_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for non-Chromium browsers.' ); + } +} diff --git a/tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php new file mode 100644 index 0000000000000..dcdba3ff94a47 --- /dev/null +++ b/tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php @@ -0,0 +1,158 @@ +original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $this->original_http_host = $_SERVER['HTTP_HOST'] ?? 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; + } + + // 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' ); + unset( $GLOBALS['current_screen'] ); + 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_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' ) ) ); + } + + /** + * The isolation callback must be wired to the screen's load hook in + * default-filters.php: the buffer has to start before media-new.php + * produces any output, and none of the gating below runs at all if + * the hook is missing. + * + * @ticket 65662 + */ + public function test_hooked_to_load_media_new() { + $this->assertSame( 10, has_action( 'load-media-new.php', 'wp_set_up_cross_origin_isolation' ) ); + } + + /** + * @ticket 65662 + */ + public function test_no_buffer_when_client_side_processing_disabled() { + $this->set_up_isolation_environment(); + + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + $level_before = ob_get_level(); + set_current_screen( 'media' ); + wp_set_up_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * @ticket 65662 + */ + public function test_no_buffer_when_logged_out() { + $this->set_up_isolation_environment(); + + wp_set_current_user( 0 ); + + $level_before = ob_get_level(); + set_current_screen( 'media' ); + wp_set_up_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * @ticket 65662 + */ + public function test_no_buffer_when_user_cannot_upload() { + $this->set_up_isolation_environment(); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $level_before = ob_get_level(); + set_current_screen( 'media' ); + wp_set_up_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 65662 + */ + public function test_starts_output_buffer_for_chromium() { + $this->set_up_isolation_environment(); + + $level_before = ob_get_level(); + set_current_screen( 'media' ); + wp_set_up_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before + 1, $level_after, 'Output buffer should be started on media-new.php for Chromium 137+.' ); + + ob_end_clean(); + } + + /** + * @ticket 65662 + */ + public function test_no_buffer_for_firefox() { + $this->set_up_isolation_environment(); + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0'; + + $level_before = ob_get_level(); + set_current_screen( 'media' ); + wp_set_up_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for non-Chromium browsers.' ); + } +} diff --git a/tsconfig.json b/tsconfig.json index c583f18b34ddd..71bada1d866f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,13 +20,18 @@ "typeRoots": [ "./typings", "./node_modules/@types" ], "types": [ "node", + "backbone", "wp-globals", + "media-uploads", "codemirror/addon/lint/lint", "codemirror/addon/hint/show-hint" ] }, "include": [], "files": [ + "src/js/_enqueues/admin/media-frame-upload.js", + "src/js/_enqueues/admin/media-new-upload.js", + "src/js/_enqueues/admin/media-upload-pipeline.js", "src/js/_enqueues/lib/codemirror/htmlhint-kses.js", "src/js/_enqueues/lib/codemirror/javascript-lint.js", "src/js/_enqueues/wp/code-editor.js", diff --git a/typings/media-uploads/index.d.ts b/typings/media-uploads/index.d.ts new file mode 100644 index 0000000000000..3f9534beb8084 --- /dev/null +++ b/typings/media-uploads/index.d.ts @@ -0,0 +1,328 @@ +/** + * Typings for the client-side media upload scripts in wp-admin: the bundled + * plupload surface they use, the helpers plupload-handlers.js exposes, the + * flags they set on `window`, and the shapes the scripts share with each + * other. + * + * Only the surface those scripts rely on is described. + */ + +declare namespace plupload { + /** + * A file queued in a plupload uploader. + */ + interface File { + id: string; + name: string; + type: string; + size: number; + loaded: number; + percent: number; + status: number; + + /** + * The underlying native File, or null when the active runtime (html4, + * say) cannot expose one. + */ + getNative(): globalThis.File | null; + } + + /** + * Settings passed to a plupload uploader. + */ + interface UploaderSettings { + /** Where files are posted; async-upload.php for attachment uploads. */ + url?: string; + multipart_params?: Record< string, string >; + + /** + * Event handlers bound while the uploader initializes, keyed by event + * name, or a function called with the uploader instead. + */ + init?: + | Record< string, ( up: Uploader, ...args: any[] ) => unknown > + | ( ( up: Uploader ) => void ); + + [ setting: string ]: unknown; + } + + /** + * A plupload uploader instance. + */ + interface Uploader { + settings?: UploaderSettings; + + bind( + name: 'FilesAdded', + callback: ( up: Uploader, files: File[] ) => unknown, + context?: unknown, + priority?: number + ): void; + bind( + name: string, + callback: ( ...args: any[] ) => unknown, + context?: unknown, + priority?: number + ): void; + removeFile( file: File ): void; + addFile( file: globalThis.File | globalThis.File[], fileName?: string ): void; + refresh(): void; + + /** + * Admin upload scripts flag an uploader they have already intercepted. + */ + [ property: string ]: unknown; + } + + /** + * Status of a file that could not be queued. + */ + const FAILED: number; +} + +declare var pluploadL10n: Record< string, string >; + +/** + * The settings media_upload_form() prints for the uploader that + * plupload-handlers.js creates on wp-admin/media-new.php. + * + * Undefined when the browser uploader is disabled. + */ +declare var wpUploaderInit: plupload.UploaderSettings | undefined; + +declare function fileQueued( file: plupload.File ): void; +declare function uploadStart(): void; +declare function uploadSuccess( file: plupload.File, serverId: string ): void; +declare function uploadComplete(): void; + +/** + * The parts of a wp.media.model.Attachment this script relies on. + */ +interface WPAttachment extends Backbone.Model {} + +/** + * The parts of a wp.Uploader instance the Media Library grid script relies + * on. + */ +interface WPUploader { + /** + * The plupload uploader it wraps. + */ + uploader: plupload.Uploader; + + /** + * Runs when a file is queued. + */ + added( model: WPAttachment ): void; + + /** + * Runs when an upload finished. + */ + success( model: WPAttachment ): void; + + /** + * Runs when an upload failed. + */ + error( + message: string, + data: Record< string, unknown >, + file: { name: string } + ): void; +} + +/** + * The placeholder attributes wp-plupload.js builds for an uploading tile. + */ +interface PlaceholderAttributes { + /** + * The file being uploaded. + */ + file: plupload.File; + + /** + * Always true while the upload runs. + */ + uploading: boolean; + + /** + * When the upload started. + */ + date: Date; + filename: string; + menuOrder: number; + + /** + * The post the upload is attached to. + */ + uploadedTo: number; + + /** + * Bytes uploaded so far. + */ + loaded: number; + + /** + * Size of the file in bytes. + */ + size: number; + + /** + * Progress percentage. + */ + percent: number; + + /** + * Mime type guessed from the file name. + */ + type?: string; + + /** + * Mime subtype guessed from the file name. + */ + subtype?: string; +} + +/** + * An item in the @wordpress/upload-media queue. + */ +interface QueueItem { + id: string; + + /** + * Set on the sub-size children of an upload. + */ + parentId?: string; + + /** + * The file being processed. + */ + sourceFile?: File; + + /** + * Progress percentage, when the store reports one. + */ + progress?: number; + + /** + * The operation being run. + */ + currentOperation?: string; + + /** + * The operations left to run. + */ + operations?: unknown[]; + + /** + * The sub-sizes sideloaded so far. + */ + subSizes?: unknown[]; +} + +/** + * The pipeline's bookkeeping for one queued upload. + */ +interface UploadEntry { + /** + * Identity key of the file. + */ + key: string; + + /** + * ID of the queue item it matched. + */ + itemId: string | null; + + /** + * Progress callback. + */ + onProgress?: ( percent: number ) => void; + + /** + * Last reported percentage. + */ + lastPercent: number; + + /** + * Operation counts. + */ + totals: { total: number; remaining: number } | null; + + /** + * Whether it finished. + */ + released: boolean; +} + +/** + * A finalized attachment as returned by the client-side pipeline. + */ +interface PipelineAttachment { + id: number; +} + +/** + * Why an upload failed. + * + * A rejected apiFetch is not always an Error: a REST failure arrives as a + * plain { code, message } object. + */ +interface UploadError { + /** + * Error code, when the pipeline supplied one. + */ + code?: string; + + /** + * Human-readable reason. + */ + message?: string; +} + +/** + * Lifecycle callbacks for a file queued with wp.mediaUploadPipeline. + */ +interface QueueFileCallbacks { + /** + * Called with the finalized attachment. + */ + onSuccess?: ( attachment: PipelineAttachment ) => void; + + /** + * Called with the upload error. + */ + onError?: ( error: UploadError ) => void; + + /** + * Called with an integer percentage (0-99) whenever it changes. + */ + onProgress?: ( percent: number ) => void; +} + +interface Window { + /** + * Settings printed for the media-upload-pipeline script. + */ + _wpMediaUploadPipelineSettings?: { + maxUploadFileSize?: number; + allowedMimeTypes?: Record< string, string > | null; + allImageSizes?: Record< string, unknown >; + bigImageSizeThreshold?: number | false; + imageStripMeta?: boolean; + imageMaxBitDepth?: number; + }; + + /** + * Set once the media-upload-pipeline script has configured the store, and + * read by the media-utils package. + */ + __clientSideMediaProcessing?: boolean; + + /** + * Guards against a screen upload script running twice. The media frame + * flag also tells other scripts, such as the Gutenberg plugin's media + * modal integration, that core already handles these uploads. + */ + __wpMediaFrameUpload?: boolean; + __wpMediaNewUpload?: boolean; +} diff --git a/typings/wp-globals/index.d.ts b/typings/wp-globals/index.d.ts index e555683101d1c..eb83595805424 100644 --- a/typings/wp-globals/index.d.ts +++ b/typings/wp-globals/index.d.ts @@ -1,5 +1,4 @@ declare var wp: any; declare var jQuery: import('jquery').JQueryStatic; declare var _: import('underscore'); -declare var Backbone: any; declare var HTMLHint: import('htmlhint').HTMLHint;