From 0d18db65f35e0c091d9c3928b862fd352d86b25d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Nov 2026 09:41:05 -0500 Subject: [PATCH 1/7] Media: Test that non-image media frame uploads stay classic. A plugin can extend the uploader's file type filter for a single frame, as page builders do for SVG and JSON. The client-side pipeline validated those files against the mime types the page was rendered with, and rejected them in the browser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjTNtbdhcmnECA2bZxP --- .../media-library-client-side-upload.test.js | 41 +++++++++++++++++++ tests/e2e/utils/media-pipeline.js | 4 ++ 2 files changed, 45 insertions(+) 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 bfa82b4f0ee54..70882a542eea9 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, @@ -567,6 +568,46 @@ 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( '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 3629fdabd2bc5..c24ef0aebc7d4 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 = { From 374ab6754a0e2253233d7fa9a149ca4ec7c38b6f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Nov 2026 09:41:05 -0500 Subject: [PATCH 2/7] Media: Keep non-image uploads on the classic path. Only route images the client-side pipeline can process (JPEG, PNG, GIF, WebP, AVIF and HEIC/HEIF) and leave every other file to plupload. The pipeline does no processing for other files, and its type check ignored the uploader's own filters, so file types a plugin allows for a single frame were rejected before reaching the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjTNtbdhcmnECA2bZxP --- .../_enqueues/admin/media-upload-pipeline.js | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js index 58dceae7affcc..d1783353183fd 100644 --- a/src/js/_enqueues/admin/media-upload-pipeline.js +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -412,16 +412,40 @@ window.wp = window.wp || {}; return configured && isStoreConfigured(); } + /** + * Whether a file is an image the client-side pipeline can process. + * + * Mirrors the formats the upload-media store processes in the browser: + * the wasm-vips formats, plus HEIC and HEIF, which it converts. 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 ) { + return ( + /^image\/(?:jpeg|png|gif|webp|avif|heic|heif)$/i.test( + file.type || '' + ) || /\.hei[cf]$/i.test( file.name || '' ) + ); + } + /** * 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. 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 +474,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 ); From 384062ed166d87fe04d9174a7cdefc4e3b535e2b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Nov 2026 13:40:43 -0500 Subject: [PATCH 3/7] Media: Keep one list of the image formats the upload pipeline takes. The formats were listed twice, once to route files to the pipeline and once to type the media frame's placeholder tiles, so adding a format (eg. JPEG XL) meant finding both. Both now read one list in media-upload-pipeline.js, exposed to media-frame-upload.js as `wp.mediaUploadPipeline.getImageSubtype()`, with a pointer to the upload-media constants it mirrors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjTNtbdhcmnECA2bZxP --- src/js/_enqueues/admin/media-frame-upload.js | 16 +++---- .../_enqueues/admin/media-upload-pipeline.js | 44 +++++++++++++++---- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/js/_enqueues/admin/media-frame-upload.js b/src/js/_enqueues/admin/media-frame-upload.js index f5af17d7991d2..cc4e6105f73bb 100644 --- a/src/js/_enqueues/admin/media-frame-upload.js +++ b/src/js/_enqueues/admin/media-frame-upload.js @@ -200,18 +200,12 @@ 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 ) { + // Early mime type scanning for images, as wp-plupload.js does, + // for the formats the pipeline processes. + 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 d1783353183fd..865465fd48936 100644 --- a/src/js/_enqueues/admin/media-upload-pipeline.js +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -38,6 +38,11 @@ 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,23 +417,43 @@ 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. * - * Mirrors the formats the upload-media store processes in the browser: - * the wasm-vips formats, plus HEIC and HEIF, which it converts. A HEIC - * file is also matched by its extension, since browsers on platforms - * without a HEIC codec may give it no type at all. + * 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 ) { - return ( - /^image\/(?:jpeg|png|gif|webp|avif|heic|heif)$/i.test( - file.type || '' - ) || /\.hei[cf]$/i.test( file.name || '' ) - ); + const type = ( file.type || '' ).toLowerCase(); + if ( + type.startsWith( 'image/' ) && + IMAGE_SUBTYPES.includes( type.slice( 6 ) ) + ) { + return true; + } + const subtype = getImageSubtype( file.name ); + return 'heic' === subtype || 'heif' === subtype; } /** @@ -759,5 +784,6 @@ window.wp = window.wp || {}; isReady: isReady, routeBatch: routeBatch, getErrorText: getErrorText, + getImageSubtype: getImageSubtype, }; } )( window.wp ); From 1c1042c573d5c129410730389ffb966220b3b84d Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Thu, 5 Nov 2026 22:34:23 -0500 Subject: [PATCH 4/7] Apply batched suggestions from code review Co-authored-by: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> --- src/js/_enqueues/admin/media-frame-upload.js | 6 ++++-- src/js/_enqueues/admin/media-upload-pipeline.js | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/js/_enqueues/admin/media-frame-upload.js b/src/js/_enqueues/admin/media-frame-upload.js index cc4e6105f73bb..464818e51e697 100644 --- a/src/js/_enqueues/admin/media-frame-upload.js +++ b/src/js/_enqueues/admin/media-frame-upload.js @@ -200,8 +200,10 @@ percent: file.percent, }; - // Early mime type scanning for images, as wp-plupload.js does, - // for the formats the pipeline processes. + /* + * Early mime type scanning for images, as wp-plupload.js does, + * for the formats the pipeline processes. + */ const subtype = pipeline.getImageSubtype( file.name ); if ( subtype ) { attributes.type = 'image'; diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js index 865465fd48936..c3275c8010e8b 100644 --- a/src/js/_enqueues/admin/media-upload-pipeline.js +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -38,9 +38,11 @@ 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. + /* + * 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' ]; /** From 3618bbe44633e65adddc916b540c5e9235d8324b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Nov 2026 23:29:25 -0500 Subject: [PATCH 5/7] Media: Test that images of an unlisted type stay classic. A plugin can allow an image type only for its own uploads, leaving it out of the allowed types the page was rendered with. The pipeline rejected those images in the browser before the server could decide. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjUDjTr7YBnLJwEFAc6 --- .../media-library-client-side-upload.test.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 df8b70064ffaf..91773312577c1 100644 --- a/tests/e2e/specs/media-library-client-side-upload.test.js +++ b/tests/e2e/specs/media-library-client-side-upload.test.js @@ -606,6 +606,56 @@ test.describe( 'Media Library grid client-side uploads', () => { ).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, From edaf5052818ca4271155c5ce301d79448b930374 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Nov 2026 23:29:25 -0500 Subject: [PATCH 6/7] Media: Leave images of an unlisted type to the classic uploader. Send a file to plupload when its type is missing from the allowed types the page was rendered with, so the server decides, as it did before the pipeline. Plupload's own filter runs first, so this only affects files a plugin let through on purpose. Props desrosj. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjUDjTr7YBnLJwEFAc6 --- .../_enqueues/admin/media-upload-pipeline.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js index c3275c8010e8b..c52cbb1a73476 100644 --- a/src/js/_enqueues/admin/media-upload-pipeline.js +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -440,14 +440,25 @@ window.wp = window.wp || {}; /** * Whether a file is an image the client-side pipeline can process. * - * A HEIC file is also matched by its extension, since browsers on - * platforms without a HEIC codec may give it no type at all. + * 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 ) ) @@ -467,7 +478,8 @@ window.wp = window.wp || {}; * 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. Audio also keeps the title and description that + * 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" From dc387edef6596ce464743b7933996dcd1b7395b3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 9 Nov 2026 14:06:12 -0500 Subject: [PATCH 7/7] Media: Re-wrap the partitionBatch() docblock. A sentence added in an earlier commit left one line far past the width the rest of the docblock wraps at. Comment-only, no behavior change. Claude-Session: https://claude.ai/code/session_018vwYjSwbaq2zmnmsrouTgd --- src/js/_enqueues/admin/media-upload-pipeline.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js index c52cbb1a73476..bd25774d48491 100644 --- a/src/js/_enqueues/admin/media-upload-pipeline.js +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -479,12 +479,13 @@ window.wp = window.wp || {}; * 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. + * 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