diff --git a/Gruntfile.js b/Gruntfile.js index 139cb6fb5b286..2570e99d41351 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -526,6 +526,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-library-upload.js' ]: [ './src/js/_enqueues/admin/media-library-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' ], @@ -1280,6 +1283,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-library-upload.js': 'src/js/_enqueues/admin/media-library-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 f1d7ebd89b2c4..60a5b0d9c6dc7 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 9bb1fd5d0af5b..a43a671c8b1b2 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-library-upload.js b/src/js/_enqueues/admin/media-library-upload.js new file mode 100644 index 0000000000000..3a87ae4ec488f --- /dev/null +++ b/src/js/_enqueues/admin/media-library-upload.js @@ -0,0 +1,257 @@ +/** + * Routes Media Library grid uploads through the client-side media pipeline. + * + * On wp-admin/upload.php (grid mode) WordPress uploads via wp.Uploader / + * plupload to async-upload.php. When the browser is cross-origin isolated + * and supports the client-side pipeline, this script intercepts the + * uploader's FilesAdded handler and 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 grid's own UI is reused: the same placeholder tiles, progress bars, + * "Uploading n/m" status, and error sidebar 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-library-upload.js + */ + +/* global plupload */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaLibraryUpload ) { + 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.__wpMediaLibraryUpload = 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 ) { + model.set( { id: attachment.id }, { silent: true } ); + + // Register the model in Attachments.all (parity with wp-plupload.js). + wp.media.model.Attachment.get( attachment.id, model ); + + model + .fetch() + .done( function () { + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( { uploading: false } ); + } ) + .fail( function () { + // Fetch failed, but the upload succeeded: clear the uploading + // state with what the pipeline gave us so no tile is stuck. + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( attachment ); + model.set( { uploading: false } ); + } ) + .always( function () { + 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 ); + } + + /** + * 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 ( ! 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 ) { + 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, including the Media Library grid's. + 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.__wpMediaLibraryUploadBound ) { + return; + } + up.__wpMediaLibraryUploadBound = true; + + // plupload sorts handlers by priority (descending) and a `false` + // return breaks the chain, so priority 100 runs before and suppresses + // the built-in FilesAdded handler. + up.bind( + 'FilesAdded', + function ( uploader, files ) { + return handleFilesAdded( wpUploader, uploader, files ); + }, + this, + 100 + ); + }; +} )(); 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..d267186a4a8a8 --- /dev/null +++ b/src/js/_enqueues/admin/media-new-upload.js @@ -0,0 +1,325 @@ +/** + * 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 ) { + 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; + } + + const init = wpUploaderInit.init; + if ( typeof init === 'function' ) { + wpUploaderInit.init = function ( up ) { + init( up ); + bindUploader( up ); + }; + } else { + wpUploaderInit.init = Object.assign( {}, init, { + PostInit: bindUploader, + } ); + } +} )(); 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..996c2564d3618 --- /dev/null +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -0,0 +1,580 @@ +/** + * Shared glue for routing classic admin uploads through the client-side + * media pipeline. + * + * The Media Library grid (media-library-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(); + + 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 ); + } + } + + /** + * 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). + * + * @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; + + /* + * 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 ); + + // Warn 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. + window.addEventListener( 'beforeunload', function ( event ) { + if ( inFlight > 0 ) { + event.preventDefault(); + // Some Chromium versions only show the prompt for returnValue. + event.returnValue = ''; + } + } ); + + return true; + } + + /** + * Whether the store has received its settings. + * + * The provider renders asynchronously, so a file added before the + * settings land must be left to classic plupload - a degradation, + * never data loss. + * + * @return {boolean} True when the store is ready to accept files. + */ + function isReady() { + if ( ! configured ) { + return false; + } + const storeSettings = wp.data.select( uploadStore ).getSettings(); + return Boolean( storeSettings && storeSettings.mediaUpload ); + } + + /** + * 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), and 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. + * + * @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; + } + if ( ! file.getNative || ! file.getNative() ) { + return false; + } + return ! /^audio\//i.test( file.type || '' ); + } ); + } + + /** + * 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, + 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-admin/upload.php b/src/wp-admin/upload.php index 7cf0f6fe10108..35ffc3d9e887c 100644 --- a/src/wp-admin/upload.php +++ b/src/wp-admin/upload.php @@ -141,6 +141,7 @@ wp_enqueue_media(); wp_enqueue_script( 'media-grid' ); wp_enqueue_script( 'media' ); + wp_enqueue_media_library_upload(); // Remove the error parameter added by deprecation of wp-admin/media.php. add_filter( diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..4e3cab772792e 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -700,6 +700,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 580ba2cb751ce..aae94b6d7f17a 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6608,9 +6608,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 +6633,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 +6681,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 +6713,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 +6725,140 @@ 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 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; + } + + 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 Media Library grid uploads through + * the client-side media processing pipeline. + * + * @since 7.2.0 + */ +function wp_enqueue_media_library_upload(): void { + _wp_enqueue_media_upload_pipeline_script( 'media-library-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 +6867,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; } diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 7d5ba24e5617d..f9bd2a89c9058 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1515,6 +1515,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-library-upload', "/wp-admin/js/media-library-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..d32abb6035086 --- /dev/null +++ b/tests/e2e/specs/media-library-client-side-upload.test.js @@ -0,0 +1,518 @@ +/** + * 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 ), + } ); + } + ); +} + +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-library-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 ); + } ); +} ); 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..a4243962f5164 --- /dev/null +++ b/tests/e2e/specs/media-new-client-side-upload.test.js @@ -0,0 +1,670 @@ +/** + * 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 ), + } ); + } + ); +} + +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(); + } ); +} ); diff --git a/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php new file mode 100644 index 0000000000000..daaa1d2149ebb --- /dev/null +++ b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php @@ -0,0 +1,258 @@ +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(); + } + + 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; + + remove_all_filters( 'wp_client_side_media_processing_enabled' ); + parent::tear_down(); + } + + /** + * @ticket 65661 + */ + public function test_script_enqueued() { + wp_enqueue_media_library_upload(); + + $this->assertTrue( wp_script_is( 'media-library-upload', 'enqueued' ) ); + } + + /** + * @ticket 65661 + */ + public function test_script_not_enqueued_when_client_side_processing_disabled() { + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + wp_enqueue_media_library_upload(); + + $this->assertFalse( wp_script_is( 'media-library-upload', 'enqueued' ) ); + } + + /** + * 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_library_upload(); + + $this->assertFalse( wp_script_is( 'media-library-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_library_upload(); + + $this->assertFalse( wp_script_is( 'media-library-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_library_upload(); + + $script = wp_scripts()->registered['media-library-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_library_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_library_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..4510760d3851e --- /dev/null +++ b/tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php @@ -0,0 +1,184 @@ +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(); + } + + 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; + + 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..63df6dffc3e0f 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-library-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..95a428b91818f --- /dev/null +++ b/typings/media-uploads/index.d.ts @@ -0,0 +1,323 @@ +/** + * 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 { + 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; + 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. + */ + __wpMediaLibraryUpload?: 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;