Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions src/js/_enqueues/admin/media-frame-upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
79 changes: 72 additions & 7 deletions src/js/_enqueues/admin/media-upload-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -735,5 +799,6 @@ window.wp = window.wp || {};
isReady: isReady,
routeBatch: routeBatch,
getErrorText: getErrorText,
getImageSubtype: getImageSubtype,
};
} )( window.wp );
91 changes: 91 additions & 0 deletions tests/e2e/specs/media-library-client-side-upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/utils/media-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading