diff --git a/src/js/_enqueues/admin/media-frame-upload.js b/src/js/_enqueues/admin/media-frame-upload.js index f5af17d7991d2..464818e51e697 100644 --- a/src/js/_enqueues/admin/media-frame-upload.js +++ b/src/js/_enqueues/admin/media-frame-upload.js @@ -202,16 +202,12 @@ /* * Early mime type scanning for images, as wp-plupload.js does, - * extended with the formats the client-side pipeline accepts. + * for the formats the pipeline processes. */ - const image = /(?:jpe?g|png|gif|webp|avif|heic|heif)$/i.exec( - file.name - ); - if ( image ) { + const subtype = pipeline.getImageSubtype( file.name ); + if ( subtype ) { 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; + attributes.subtype = subtype; } const model = wp.media.model.Attachment.create( attributes ); diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js index 58dceae7affcc..bd25774d48491 100644 --- a/src/js/_enqueues/admin/media-upload-pipeline.js +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -38,6 +38,13 @@ window.wp = window.wp || {}; const imageSizeCount = Object.keys( settings.allImageSizes || {} ).length; + /* + * Image subtypes the pipeline processes. Keep in sync with + * CLIENT_SIDE_SUPPORTED_MIME_TYPES and HEIC_MIME_TYPES in the + * upload-media package. + */ + const IMAGE_SUBTYPES = [ 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif' ]; + /** * Recursively appends data to a FormData object, supporting nested objects. * @@ -412,16 +419,73 @@ window.wp = window.wp || {}; return configured && isStoreConfigured(); } + /** + * Gets the image subtype of a file name the pipeline can process. + * + * @param {string} fileName A file name. + * @return {string|null} The subtype, eg. `jpeg` for `photo.jpg`, or + * null when the pipeline does not process it. + */ + function getImageSubtype( fileName ) { + const match = /\.([a-z0-9]+)$/i.exec( fileName || '' ); + if ( ! match ) { + return null; + } + // `jpg` is not a valid subtype, so map it to `jpeg`. + const extension = match[ 1 ].toLowerCase(); + const subtype = 'jpg' === extension ? 'jpeg' : extension; + return IMAGE_SUBTYPES.includes( subtype ) ? subtype : null; + } + + /** + * Whether a file is an image the client-side pipeline can process. + * + * A file whose type is missing from the allowed types the page was + * rendered with is left to the server, which can allow it for a single + * request (see partitionBatch()). A HEIC file is also matched by its + * extension, since browsers on platforms without a HEIC codec may give + * it no type at all. + * + * @param {plupload.File} file A file added to the plupload queue. + * @return {boolean} True when the pipeline should take the file. + */ + function isPipelineFile( file ) { + const type = ( file.type || '' ).toLowerCase(); + const allowedTypes = settings.allowedMimeTypes; + if ( + type && + allowedTypes && + ! Object.values( allowedTypes ).includes( type ) + ) { + return false; + } + if ( + type.startsWith( 'image/' ) && + IMAGE_SUBTYPES.includes( type.slice( 6 ) ) + ) { + return true; + } + const subtype = getImageSubtype( file.name ); + return 'heic' === subtype || 'heif' === subtype; + } + /** * Splits a batch of plupload files between the pipeline and classic * plupload. * - * Audio files stay classic so they keep the title and description that - * media_handle_upload() derives from their ID3 tags, which the REST - * endpoint does not do. So do JPEG and PNG images while plupload's own - * client-side resize is enabled (the "Scale images" setting, or a - * `resize` passed through `plupload_init`): plupload scales those down - * before uploading them, the pipeline would not. + * Only images the pipeline can process are taken (see isPipelineFile()). + * Everything else, such as audio, video, PDF, SVG or JSON, stays + * classic: the pipeline would do nothing for it but upload it, and + * would check its type against the allowed types the page was rendered + * with instead of the uploader's own filters, which a plugin can extend + * for a single frame. For the same reason an image of a type the page + * was not rendered with stays classic, so the server decides. Audio + * also keeps the title and description that media_handle_upload() + * derives from its ID3 tags, which the REST endpoint does not. JPEG + * and PNG images stay classic too while plupload's own client-side + * resize is enabled (the "Scale images" setting, or a `resize` passed + * through `plupload_init`): plupload scales those down before + * uploading them, the pipeline would not. * * The whole batch stays classic when a file cannot be re-queued on * plupload or is already plupload's: plupload returns no native File @@ -450,7 +514,7 @@ window.wp = window.wp || {}; } const type = file.type || ''; if ( - /^audio\//i.test( type ) || + ! isPipelineFile( file ) || ( isResizing && /^image\/(?:jpeg|png)$/i.test( type ) ) ) { batch.classic.push( file ); @@ -735,5 +799,6 @@ window.wp = window.wp || {}; isReady: isReady, routeBatch: routeBatch, getErrorText: getErrorText, + getImageSubtype: getImageSubtype, }; } )( window.wp ); diff --git a/tests/e2e/specs/media-library-client-side-upload.test.js b/tests/e2e/specs/media-library-client-side-upload.test.js index 0b2dd0ad5e3ac..91773312577c1 100644 --- a/tests/e2e/specs/media-library-client-side-upload.test.js +++ b/tests/e2e/specs/media-library-client-side-upload.test.js @@ -9,6 +9,7 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; import { TEST_IMAGE_PATH, TEST_AUDIO_PATH, + TEST_SVG_PATH, SIMULATED_ERROR, UNDECODABLE_HEIC, failMediaCreate, @@ -565,6 +566,96 @@ test.describe( 'Media Library grid client-side uploads', () => { expect( restUploadCount ).toBe( 0 ); } ); + test( 'leaves files that are not processable images to the classic uploader', async ( { + page, + admin, + } ) => { + // A plugin can allow an extra type on its own uploader, as page + // builders do for SVG, without adding it to the allowed mime types + // the page was rendered with. + await overridePluploadDefaults( page, { + filters: { mime_types: [ { extensions: 'svg' } ] }, + } ); + await stubClassicUpload( page ); + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + const counts = countUploadRequests( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await expect( fileInput ).toBeAttached( { timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_SVG_PATH ); + + // The uploader's own filter decides, so the file reaches the server + // instead of being rejected in the browser. + await expect + .poll( () => counts.asyncUpload, { timeout: 60_000 } ) + .toBeGreaterThanOrEqual( 1 ); + expect( counts.rest ).toBe( 0 ); + await expect( + page.locator( '.upload-error-message', { + hasText: 'not allowed to upload this file type', + } ) + ).toHaveCount( 0 ); + } ); + + test( 'leaves images of a type the page was not rendered with to the classic uploader', async ( { + page, + admin, + } ) => { + // A plugin can allow an image type only for its own requests, so + // the allowed mime types the page was rendered with leave it out + // while the uploader's own filter lets it through. + await page.addInitScript( () => { + let settings; + Object.defineProperty( window, '_wpMediaUploadPipelineSettings', { + configurable: true, + get() { + return settings; + }, + set( value ) { + settings = value; + const allowed = value && value.allowedMimeTypes; + for ( const extension in allowed || {} ) { + if ( 'image/jpeg' === allowed[ extension ] ) { + delete allowed[ extension ]; + } + } + }, + } ); + } ); + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + const counts = countUploadRequests( page ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await expect( fileInput ).toBeAttached( { timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // The server decides, as it would without the pipeline. + await expect( tile( page, 'test-image' ) ).toBeVisible( { + timeout: 60_000, + } ); + expect( counts.asyncUpload ).toBeGreaterThanOrEqual( 1 ); + expect( counts.rest ).toBe( 0 ); + await expect( page.locator( '.upload-error' ) ).toHaveCount( 0 ); + } ); + test( 'splits a batch of an image and audio between the pipeline and the classic uploader', async ( { page, admin, diff --git a/tests/e2e/utils/media-pipeline.js b/tests/e2e/utils/media-pipeline.js index c01dc05ebc72d..5db0b7c24779f 100644 --- a/tests/e2e/utils/media-pipeline.js +++ b/tests/e2e/utils/media-pipeline.js @@ -18,6 +18,10 @@ export const TEST_AUDIO_PATH = path.join( '../assets/test-audio.wav' ); +// An SVG: like any file that is not an image the pipeline can process, it +// stays on the classic upload path. +export const TEST_SVG_PATH = path.join( __dirname, '../assets/sample.svg' ); + // A REST error the pipeline surfaces as-is: its message matches none of the // transient patterns @wordpress/upload-media retries on. export const SIMULATED_ERROR = {