diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index 8dfdf2ba7f2d5c..55d79babd1dacc 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -25,6 +25,7 @@ { "path": "../i18n" }, { "path": "../icons" }, { "path": "../keycodes" }, + { "path": "../media-utils" }, { "path": "../notices" }, { "path": "../plugins" }, { "path": "../private-apis" }, diff --git a/packages/media-utils/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index 7ab4d4a5794d88..26282b6b2bda21 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- Rewrite in TypeScript, exporting all the individual utility functions. + ## 5.7.0 (2024-09-05) ## 5.6.0 (2024-08-21) diff --git a/packages/media-utils/README.md b/packages/media-utils/README.md index 6c2384061a5f6f..1adc15186078a5 100644 --- a/packages/media-utils/README.md +++ b/packages/media-utils/README.md @@ -13,6 +13,75 @@ npm install @wordpress/media-utils --save _This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ +## API + + + +### Attachment + +Undocumented declaration. + +### MediaUpload + +Undocumented declaration. + +### RestAttachment + +Undocumented declaration. + +### transformAttachment + +Transforms an attachment object from the REST API shape into the shape expected by the block editor and other consumers. + +_Parameters_ + +- _attachment_ `RestAttachment`: REST API attachment object. + +### uploadMedia + +Upload a media file when the file upload button is activated or when adding a file to the editor via drag & drop. + +_Parameters_ + +- _$0_ `UploadMediaArgs`: Parameters object passed to the function. +- _$0.allowedTypes_ `UploadMediaArgs[ 'allowedTypes' ]`: Array with the types of media that can be uploaded, if unset all types are allowed. +- _$0.additionalData_ `UploadMediaArgs[ 'additionalData' ]`: Additional data to include in the request. +- _$0.filesList_ `UploadMediaArgs[ 'filesList' ]`: List of files. +- _$0.maxUploadFileSize_ `UploadMediaArgs[ 'maxUploadFileSize' ]`: Maximum upload size in bytes allowed for the site. +- _$0.onError_ `UploadMediaArgs[ 'onError' ]`: Function called when an error happens. +- _$0.onFileChange_ `UploadMediaArgs[ 'onFileChange' ]`: Function called each time a file or a temporary representation of the file is available. +- _$0.wpAllowedMimeTypes_ `UploadMediaArgs[ 'wpAllowedMimeTypes' ]`: List of allowed mime types and file extensions. +- _$0.signal_ `UploadMediaArgs[ 'signal' ]`: Abort signal. + +### validateFileSize + +Verifies whether the file is within the file upload size limits for the site. + +_Parameters_ + +- _file_ `File`: File object. +- _maxUploadFileSize_ `number`: Maximum upload size in bytes allowed for the site. + +### validateMimeType + +Verifies if the caller (e.g. a block) supports this mime type. + +_Parameters_ + +- _file_ `File`: File object. +- _allowedTypes_ `string[]`: List of allowed mime types. + +### validateMimeTypeForUser + +Verifies if the user is allowed to upload this mime type. + +_Parameters_ + +- _file_ `File`: File object. +- _wpAllowedMimeTypes_ `Record< string, string > | null`: List of allowed mime types and file extensions. + + + ## Usage ### uploadMedia @@ -43,7 +112,7 @@ Beware that first onFileChange is called with temporary blob URLs and then with ### MediaUpload Media upload component provides a UI button that allows users to open the WordPress media library. It is normally used in conjunction with the filter `editor.MediaUpload`. -The component follows the interface specified in [https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/media-upload/README.md](https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/media-upload/README.md), and more details regarding its usage can be checked there. +The component follows the interface specified in , and more details regarding its usage can be checked there. ## Contributing to this package diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index b1737ff39e4bcd..1cfe019e89d0a6 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -14,7 +14,7 @@ "repository": { "type": "git", "url": "https://github.com/WordPress/gutenberg.git", - "directory": "packages/url" + "directory": "packages/media-utils" }, "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" @@ -25,6 +25,7 @@ }, "main": "build/index.js", "module": "build-module/index.js", + "types": "build-types", "dependencies": { "@babel/runtime": "^7.16.0", "@wordpress/api-fetch": "file:../api-fetch", diff --git a/packages/media-utils/src/index.js b/packages/media-utils/src/index.js deleted file mode 100644 index 590a7f4c9d188d..00000000000000 --- a/packages/media-utils/src/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from './components'; -export * from './utils'; diff --git a/packages/media-utils/src/index.ts b/packages/media-utils/src/index.ts new file mode 100644 index 00000000000000..c37814afe7c48f --- /dev/null +++ b/packages/media-utils/src/index.ts @@ -0,0 +1,9 @@ +export * from './components'; + +export { uploadMedia } from './utils/upload-media'; +export { transformAttachment } from './utils/transform-attachment'; +export { validateFileSize } from './utils/validate-file-size'; +export { validateMimeType } from './utils/validate-mime-type'; +export { validateMimeTypeForUser } from './utils/validate-mime-type-for-user'; + +export type { Attachment, RestAttachment } from './utils/types'; diff --git a/packages/media-utils/src/utils/flatten-form-data.ts b/packages/media-utils/src/utils/flatten-form-data.ts new file mode 100644 index 00000000000000..594f172ef7eb2c --- /dev/null +++ b/packages/media-utils/src/utils/flatten-form-data.ts @@ -0,0 +1,33 @@ +/** + * Determines whether the passed argument appears to be a plain object. + * + * @param data The object to inspect. + */ +function isPlainObject( data: unknown ): data is Record< string, unknown > { + return ( + data !== null && + typeof data === 'object' && + Object.getPrototypeOf( data ) === Object.prototype + ); +} + +/** + * Recursively flatten data passed to form data, to allow using multi-level objects. + * + * @param {FormData} formData Form data object. + * @param {string} key Key to amend to form data object + * @param {string|Object} data Data to be amended to form data. + */ +export function flattenFormData( + formData: FormData, + key: string, + data: string | undefined | Record< string, string > +) { + if ( isPlainObject( data ) ) { + for ( const [ name, value ] of Object.entries( data ) ) { + flattenFormData( formData, `${ key }[${ name }]`, value ); + } + } else if ( data !== undefined ) { + formData.append( key, String( data ) ); + } +} diff --git a/packages/media-utils/src/utils/get-mime-types-array.ts b/packages/media-utils/src/utils/get-mime-types-array.ts new file mode 100644 index 00000000000000..d4940d36cd6ae5 --- /dev/null +++ b/packages/media-utils/src/utils/get-mime-types-array.ts @@ -0,0 +1,29 @@ +/** + * Browsers may use unexpected mime types, and they differ from browser to browser. + * This function computes a flexible array of mime types from the mime type structured provided by the server. + * Converts { jpg|jpeg|jpe: "image/jpeg" } into [ "image/jpeg", "image/jpg", "image/jpeg", "image/jpe" ] + * + * @param {?Object} wpMimeTypesObject Mime type object received from the server. + * Extensions are keys separated by '|' and values are mime types associated with an extension. + * + * @return An array of mime types or null + */ +export function getMimeTypesArray( + wpMimeTypesObject?: Record< string, string > | null +) { + if ( ! wpMimeTypesObject ) { + return null; + } + return Object.entries( wpMimeTypesObject ).flatMap( + ( [ extensionsString, mime ] ) => { + const [ type ] = mime.split( '/' ); + const extensions = extensionsString.split( '|' ); + return [ + mime, + ...extensions.map( + ( extension ) => `${ type }/${ extension }` + ), + ]; + } + ); +} diff --git a/packages/media-utils/src/utils/index.js b/packages/media-utils/src/utils/index.js deleted file mode 100644 index 509b62f1e88648..00000000000000 --- a/packages/media-utils/src/utils/index.js +++ /dev/null @@ -1 +0,0 @@ -export { uploadMedia } from './upload-media'; diff --git a/packages/media-utils/src/utils/test/flatten-form-data.ts b/packages/media-utils/src/utils/test/flatten-form-data.ts new file mode 100644 index 00000000000000..458dfd26c8f6ed --- /dev/null +++ b/packages/media-utils/src/utils/test/flatten-form-data.ts @@ -0,0 +1,49 @@ +/** + * Internal dependencies + */ +import { flattenFormData } from '../flatten-form-data'; + +describe( 'flattenFormData', () => { + it( 'should flatten nested data structure', () => { + const data = new FormData(); + + class RichTextData { + toString() { + return 'i am rich text'; + } + } + + const additionalData = { + foo: null, + bar: 1234, + meta: { + nested: 'foo', + dothis: true, + dothat: false, + supermeta: { + nested: 'baz', + }, + }, + customClass: new RichTextData(), + }; + + for ( const [ key, value ] of Object.entries( additionalData ) ) { + flattenFormData( + data, + key, + value as Parameters< typeof flattenFormData >[ 2 ] + ); + } + + const actual = Object.fromEntries( data.entries() ); + expect( actual ).toStrictEqual( { + bar: '1234', + foo: 'null', + 'meta[dothat]': 'false', + 'meta[dothis]': 'true', + 'meta[nested]': 'foo', + 'meta[supermeta][nested]': 'baz', + customClass: 'i am rich text', + } ); + } ); +} ); diff --git a/packages/media-utils/src/utils/test/get-mime-types-array.ts b/packages/media-utils/src/utils/test/get-mime-types-array.ts new file mode 100644 index 00000000000000..156955373bd0da --- /dev/null +++ b/packages/media-utils/src/utils/test/get-mime-types-array.ts @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import { getMimeTypesArray } from '../get-mime-types-array'; + +describe( 'getMimeTypesArray', () => { + it( 'should return null if it is "falsy" e.g: undefined or null', () => { + expect( getMimeTypesArray( null ) ).toEqual( null ); + expect( getMimeTypesArray( undefined ) ).toEqual( null ); + } ); + + it( 'should return an empty array if an empty object is passed', () => { + expect( getMimeTypesArray( {} ) ).toEqual( [] ); + } ); + + it( 'should return the type plus a new mime type with type and subtype with the extension if a type is passed', () => { + expect( getMimeTypesArray( { ext: 'chicken' } ) ).toEqual( [ + 'chicken', + 'chicken/ext', + ] ); + } ); + + it( 'should return the mime type passed and a new mime type with type and the extension as subtype', () => { + expect( getMimeTypesArray( { ext: 'chicken/ribs' } ) ).toEqual( [ + 'chicken/ribs', + 'chicken/ext', + ] ); + } ); + + it( 'should return the mime type passed and an additional mime type per extension supported', () => { + expect( getMimeTypesArray( { 'jpg|jpeg|jpe': 'image/jpeg' } ) ).toEqual( + [ 'image/jpeg', 'image/jpg', 'image/jpeg', 'image/jpe' ] + ); + } ); + + it( 'should handle multiple mime types', () => { + expect( + getMimeTypesArray( { 'ext|aaa': 'chicken/ribs', aaa: 'bbb' } ) + ).toEqual( [ + 'chicken/ribs', + 'chicken/ext', + 'chicken/aaa', + 'bbb', + 'bbb/aaa', + ] ); + } ); +} ); diff --git a/packages/media-utils/src/utils/test/upload-error.ts b/packages/media-utils/src/utils/test/upload-error.ts new file mode 100644 index 00000000000000..4d5f025ed8cf39 --- /dev/null +++ b/packages/media-utils/src/utils/test/upload-error.ts @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import { UploadError } from '../upload-error'; + +describe( 'UploadError', () => { + it( 'holds error code and file name', () => { + const file = new File( [], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + + const error = new UploadError( { + code: 'some_error', + message: 'An error occurred', + file, + } ); + + expect( error ).toStrictEqual( expect.any( Error ) ); + expect( error.code ).toBe( 'some_error' ); + expect( error.message ).toBe( 'An error occurred' ); + expect( error.file ).toBe( file ); + } ); +} ); diff --git a/packages/media-utils/src/utils/test/upload-media.test.js b/packages/media-utils/src/utils/test/upload-media.ts similarity index 58% rename from packages/media-utils/src/utils/test/upload-media.test.js rename to packages/media-utils/src/utils/test/upload-media.ts index fa9adc9f408815..b5075255ad4c81 100644 --- a/packages/media-utils/src/utils/test/upload-media.test.js +++ b/packages/media-utils/src/utils/test/upload-media.ts @@ -1,19 +1,18 @@ -/** - * WordPress dependencies - */ -import { createBlobURL } from '@wordpress/blob'; -import apiFetch from '@wordpress/api-fetch'; - /** * Internal dependencies */ -import { uploadMedia, getMimeTypesArray } from '../upload-media'; +import { uploadMedia } from '../upload-media'; +import { UploadError } from '../upload-error'; +import { uploadToServer } from '../upload-to-server'; + +jest.mock( '../upload-to-server', () => ( { + uploadToServer: jest.fn(), +} ) ); jest.mock( '@wordpress/blob', () => ( { createBlobURL: jest.fn(), revokeBlobURL: jest.fn(), } ) ); -jest.mock( '@wordpress/api-fetch', () => jest.fn() ); const xmlFile = new window.File( [ 'fake_file' ], 'test.xml', { type: 'text/xml', @@ -23,6 +22,10 @@ const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', { } ); describe( 'uploadMedia', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + it( 'should do nothing on no files', async () => { const onError = jest.fn(); const onFileChange = jest.fn(); @@ -33,7 +36,7 @@ describe( 'uploadMedia', () => { } ); expect( onError ).not.toHaveBeenCalled(); - expect( onFileChange ).not.toHaveBeenCalled(); + expect( uploadToServer ).not.toHaveBeenCalled(); } ); it( 'should error if allowedTypes contains a partial mime type and the validation fails', async () => { @@ -47,11 +50,14 @@ describe( 'uploadMedia', () => { } ); expect( onError ).toHaveBeenCalledWith( - expect.objectContaining( { + new UploadError( { code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.xml: Sorry, this file type is not supported here.', + file: xmlFile, } ) ); - expect( onFileChange ).not.toHaveBeenCalled(); + expect( uploadToServer ).not.toHaveBeenCalled(); } ); it( 'should error if allowedTypes contains a complete mime type and the validation fails', async () => { @@ -65,17 +71,17 @@ describe( 'uploadMedia', () => { } ); expect( onError ).toHaveBeenCalledWith( - expect.objectContaining( { + new UploadError( { code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.jpeg: Sorry, this file type is not supported here.', + file: xmlFile, } ) ); - expect( onFileChange ).not.toHaveBeenCalled(); + expect( uploadToServer ).not.toHaveBeenCalled(); } ); it( 'should work if allowedTypes contains a complete mime type and the validation succeeds', async () => { - createBlobURL.mockReturnValue( 'blob:fake_blob' ); - apiFetch.mockResolvedValue( { title: { raw: 'Test' } } ); - const onError = jest.fn(); const onFileChange = jest.fn(); await uploadMedia( { @@ -83,10 +89,11 @@ describe( 'uploadMedia', () => { filesList: [ imageFile ], onError, onFileChange, + wpAllowedMimeTypes: { jpeg: 'image/jpeg' }, } ); expect( onError ).not.toHaveBeenCalled(); - expect( onFileChange ).toHaveBeenCalledTimes( 2 ); + expect( uploadToServer ).toHaveBeenCalled(); } ); it( 'should error if allowedTypes contains multiple types and the validation fails', async () => { @@ -100,17 +107,17 @@ describe( 'uploadMedia', () => { } ); expect( onError ).toHaveBeenCalledWith( - expect.objectContaining( { + new UploadError( { code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.xml: Sorry, this file type is not supported here.', + file: xmlFile, } ) ); - expect( onFileChange ).not.toHaveBeenCalled(); + expect( uploadToServer ).not.toHaveBeenCalled(); } ); it( 'should work if allowedTypes contains multiple types and the validation succeeds', async () => { - createBlobURL.mockReturnValue( 'blob:fake_blob' ); - apiFetch.mockResolvedValue( { title: { raw: 'Test' } } ); - const onError = jest.fn(); const onFileChange = jest.fn(); await uploadMedia( { @@ -118,16 +125,14 @@ describe( 'uploadMedia', () => { filesList: [ imageFile ], onError, onFileChange, + wpAllowedMimeTypes: { jpeg: 'image/jpeg', mp4: 'video/mp4' }, } ); expect( onError ).not.toHaveBeenCalled(); - expect( onFileChange ).toHaveBeenCalledTimes( 2 ); + expect( uploadToServer ).toHaveBeenCalled(); } ); it( 'should only fail the invalid file and still allow others to succeed when uploading multiple files', async () => { - createBlobURL.mockReturnValue( 'blob:fake_blob' ); - apiFetch.mockResolvedValue( { title: { raw: 'Test' } } ); - const onError = jest.fn(); const onFileChange = jest.fn(); await uploadMedia( { @@ -135,15 +140,18 @@ describe( 'uploadMedia', () => { filesList: [ imageFile, xmlFile ], onError, onFileChange, + wpAllowedMimeTypes: { jpeg: 'image/jpeg' }, } ); expect( onError ).toHaveBeenCalledWith( - expect.objectContaining( { + new UploadError( { code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.xml: Sorry, you are not allowed to upload this file type.', file: xmlFile, } ) ); - expect( onFileChange ).toHaveBeenCalledTimes( 2 ); + expect( uploadToServer ).toHaveBeenCalledTimes( 1 ); } ); it( 'should error if the file size is greater than the maximum', async () => { @@ -155,19 +163,22 @@ describe( 'uploadMedia', () => { maxUploadFileSize: 1, onError, onFileChange, + wpAllowedMimeTypes: { jpeg: 'image/jpeg' }, } ); expect( onError ).toHaveBeenCalledWith( - expect.objectContaining( { + new UploadError( { code: 'SIZE_ABOVE_LIMIT', + message: + 'test.jpeg: This file exceeds the maximum upload size for this site.', + file: imageFile, } ) ); - expect( onFileChange ).not.toHaveBeenCalled(); + expect( uploadToServer ).not.toHaveBeenCalled(); } ); it( 'should call error handler with the correct error object if file type is not allowed for user', async () => { const onError = jest.fn(); - const onFileChange = jest.fn(); await uploadMedia( { allowedTypes: [ 'image' ], filesList: [ imageFile ], @@ -176,53 +187,13 @@ describe( 'uploadMedia', () => { } ); expect( onError ).toHaveBeenCalledWith( - expect.objectContaining( { + new UploadError( { code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', + message: + 'test.jpeg: Sorry, you are not allowed to upload this file type.', + file: imageFile, } ) ); - expect( onFileChange ).not.toHaveBeenCalled(); - } ); -} ); - -describe( 'getMimeTypesArray', () => { - it( 'should return the parameter passed if it is "falsy" e.g: undefined or null', () => { - expect( getMimeTypesArray( null ) ).toEqual( null ); - expect( getMimeTypesArray( undefined ) ).toEqual( undefined ); - } ); - - it( 'should return an empty array if an empty object is passed', () => { - expect( getMimeTypesArray( {} ) ).toEqual( [] ); - } ); - - it( 'should return the type plus a new mime type with type and subtype with the extension if a type is passed', () => { - expect( getMimeTypesArray( { ext: 'chicken' } ) ).toEqual( [ - 'chicken', - 'chicken/ext', - ] ); - } ); - - it( 'should return the mime type passed and a new mime type with type and the extension as subtype', () => { - expect( getMimeTypesArray( { ext: 'chicken/ribs' } ) ).toEqual( [ - 'chicken/ribs', - 'chicken/ext', - ] ); - } ); - - it( 'should return the mime type passed and an additional mime type per extension supported', () => { - expect( getMimeTypesArray( { 'jpg|jpeg|jpe': 'image/jpeg' } ) ).toEqual( - [ 'image/jpeg', 'image/jpg', 'image/jpeg', 'image/jpe' ] - ); - } ); - - it( 'should handle multiple mime types', () => { - expect( - getMimeTypesArray( { 'ext|aaa': 'chicken/ribs', aaa: 'bbb' } ) - ).toEqual( [ - 'chicken/ribs', - 'chicken/ext', - 'chicken/aaa', - 'bbb', - 'bbb/aaa', - ] ); + expect( uploadToServer ).not.toHaveBeenCalled(); } ); } ); diff --git a/packages/media-utils/src/utils/test/validate-file-size.ts b/packages/media-utils/src/utils/test/validate-file-size.ts new file mode 100644 index 00000000000000..31d6af0e7e4a55 --- /dev/null +++ b/packages/media-utils/src/utils/test/validate-file-size.ts @@ -0,0 +1,70 @@ +/** + * Internal dependencies + */ +import { validateFileSize } from '../validate-file-size'; +import { UploadError } from '../upload-error'; + +const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', { + type: 'image/jpeg', +} ); + +const emptyFile = new window.File( [], 'test.jpeg', { + type: 'image/jpeg', +} ); + +describe( 'validateFileSize', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should error if the file is empty', () => { + expect( () => { + validateFileSize( emptyFile ); + } ).toThrow( + new UploadError( { + code: 'EMPTY_FILE', + message: 'test.jpeg: This file is empty.', + file: imageFile, + } ) + ); + } ); + + it( 'should error if the file is is greater than the maximum', () => { + expect( () => { + validateFileSize( imageFile, 2 ); + } ).toThrow( + new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: + 'test.jpeg: This file exceeds the maximum upload size for this site.', + file: imageFile, + } ) + ); + } ); + + it( 'should not error if the file is below the limit', () => { + expect( () => { + validateFileSize( imageFile, 100 ); + } ).not.toThrow( + new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: + 'test.jpeg: This file exceeds the maximum upload size for this site.', + file: imageFile, + } ) + ); + } ); + + it( 'should not error if there is no limit', () => { + expect( () => { + validateFileSize( imageFile ); + } ).not.toThrow( + new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: + 'test.jpeg: This file exceeds the maximum upload size for this site.', + file: imageFile, + } ) + ); + } ); +} ); diff --git a/packages/media-utils/src/utils/test/validate-mime-type-for-user.ts b/packages/media-utils/src/utils/test/validate-mime-type-for-user.ts new file mode 100644 index 00000000000000..d2566566862142 --- /dev/null +++ b/packages/media-utils/src/utils/test/validate-mime-type-for-user.ts @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ +import { validateMimeTypeForUser } from '../validate-mime-type-for-user'; +import { UploadError } from '../upload-error'; + +const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', { + type: 'image/jpeg', +} ); + +describe( 'validateMimeTypeForUser', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should not error if wpAllowedMimeTypes is null or missing', async () => { + expect( () => { + validateMimeTypeForUser( imageFile ); + } ).not.toThrow(); + expect( () => { + validateMimeTypeForUser( imageFile, null ); + } ).not.toThrow(); + } ); + + it( 'should error if file type is not allowed for user', async () => { + expect( () => { + validateMimeTypeForUser( imageFile, { aac: 'audio/aac' } ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', + message: + 'test.jpeg: Sorry, you are not allowed to upload this file type.', + file: imageFile, + } ) + ); + } ); +} ); diff --git a/packages/media-utils/src/utils/test/validate-mime-type.ts b/packages/media-utils/src/utils/test/validate-mime-type.ts new file mode 100644 index 00000000000000..a83cdcefe5f99a --- /dev/null +++ b/packages/media-utils/src/utils/test/validate-mime-type.ts @@ -0,0 +1,57 @@ +/** + * Internal dependencies + */ +import { validateMimeType } from '../validate-mime-type'; +import { UploadError } from '../upload-error'; + +const xmlFile = new window.File( [ 'fake_file' ], 'test.xml', { + type: 'text/xml', +} ); +const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', { + type: 'image/jpeg', +} ); + +describe( 'validateMimeType', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should error if allowedTypes contains a partial mime type and the validation fails', async () => { + expect( () => { + validateMimeType( xmlFile, [ 'image' ] ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.xml: Sorry, this file type is not supported here.', + file: xmlFile, + } ) + ); + } ); + + it( 'should error if allowedTypes contains a complete mime type and the validation fails', async () => { + expect( () => { + validateMimeType( imageFile, [ 'image/gif' ] ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.jpeg: Sorry, this file type is not supported here.', + file: xmlFile, + } ) + ); + } ); + + it( 'should error if allowedTypes contains multiple types and the validation fails', async () => { + expect( () => { + validateMimeType( xmlFile, [ 'video', 'image' ] ); + } ).toThrow( + new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: + 'test.xml: Sorry, this file type is not supported here.', + file: xmlFile, + } ) + ); + } ); +} ); diff --git a/packages/media-utils/src/utils/transform-attachment.ts b/packages/media-utils/src/utils/transform-attachment.ts new file mode 100644 index 00000000000000..a97287872f24af --- /dev/null +++ b/packages/media-utils/src/utils/transform-attachment.ts @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import type { Attachment, RestAttachment } from './types'; + +/** + * Transforms an attachment object from the REST API shape into the shape expected by the block editor and other consumers. + * + * @param attachment REST API attachment object. + */ +export function transformAttachment( attachment: RestAttachment ): Attachment { + // eslint-disable-next-line camelcase + const { alt_text, source_url, ...savedMediaProps } = attachment; + return { + ...savedMediaProps, + alt: attachment.alt_text, + caption: attachment.caption?.raw ?? '', + title: attachment.title.raw, + url: attachment.source_url, + poster: + attachment._embedded?.[ 'wp:featuredmedia' ]?.[ 0 ]?.source_url || + undefined, + }; +} diff --git a/packages/media-utils/src/utils/types.ts b/packages/media-utils/src/utils/types.ts new file mode 100644 index 00000000000000..e05536400a7604 --- /dev/null +++ b/packages/media-utils/src/utils/types.ts @@ -0,0 +1,207 @@ +/** + * A media attachment object in a REST API context. + * + * Simplified version of what's defined in the wp-types package. + * + * @see https://www.npmjs.com/package/wp-types + */ +interface WP_REST_API_Attachment { + /** + * Unique identifier for the attachment. + */ + id: number; + /** + * The ID of the featured media for the post. + */ + featured_media: number; + /** + * URL to the attachment. + */ + link: string; + /** + * The date the attachment was published, in the site's timezone. + */ + date: string; + /** + * The date the attachment was published, as GMT. + */ + date_gmt: string; + /** + * The date the attachment was last modified, in the site's timezone. + */ + modified: string; + /** + * The date the attachment was last modified, as GMT. + */ + modified_gmt: string; + /** + * An alphanumeric identifier for the attachment unique to its type. + */ + slug: string; + /** + * A named status for the attachment. + */ + status: string; + /** + * Type of Post for the attachment. + */ + type: 'attachment'; + /** + * Alternative text to display when attachment is not displayed. + */ + alt_text: string; + /** + * The attachment caption. + */ + caption: { + /** + * Caption for the attachment, as it exists in the database. Only present when using the 'edit' context. + */ + raw?: string; + /** + * HTML caption for the attachment, transformed for display. + */ + rendered: string; + }; + /** + * The attachment description. + */ + description: { + /** + * Description for the attachment, as it exists in the database. Only present when using the 'edit' context. + */ + raw?: string; + /** + * HTML description for the attachment, transformed for display. + */ + rendered: string; + }; + /** + * Attachment type. + */ + media_type: 'image' | 'file'; + /** + * The attachment MIME type. + */ + mime_type: string; + /** + * Details about the media file, specific to its type. + */ + media_details: { + [ k: string ]: unknown; + }; + /** + * The ID for the associated post of the attachment. + */ + post: number | null; + /** + * URL to the original attachment file. + */ + source_url: string; + /** + * List of the missing image sizes of the attachment. Only present when using the 'edit' context. + */ + missing_image_sizes?: string[]; + /** + * Permalink template for the attachment. Only present when using the 'edit' context and the post type is public. + */ + permalink_template?: string; + /** + * Slug automatically generated from the attachment title. Only present when using the 'edit' context and the post type is public. + */ + generated_slug?: string; + /** + * An array of the class names for the post container element. + */ + class_list: string[]; + /** + * The title for the attachment. + */ + title: { + /** + * Title for the attachment, as it exists in the database. Only present when using the 'edit' context. + */ + raw?: string; + /** + * HTML title for the attachment, transformed for display. + */ + rendered: string; + }; + /** + * The ID for the author of the attachment. + */ + author: number; + /** + * Whether or not comments are open on the attachment. + */ + comment_status: string; + /** + * Whether or not the attachment can be pinged. + */ + ping_status: string; + /** + * Meta fields. + */ + meta: + | [] + | { + [ k: string ]: unknown; + }; + /** + * The theme file to use to display the attachment. + */ + template: string; + _links: { + [ k: string ]: { + href: string; + embeddable?: boolean; + [ k: string ]: unknown; + }[]; + }; + /** + * The embedded representation of relations. Only present when the '_embed' query parameter is set. + */ + _embedded?: { + /** + * The author of the post. + */ + author: unknown[]; + /** + * The featured image post. + */ + 'wp:featuredmedia'?: WP_REST_API_Attachment[]; + [ k: string ]: unknown; + }; + [ k: string ]: unknown; +} + +/** + * REST API attachment object with additional fields added by this project. + */ +export interface RestAttachment extends WP_REST_API_Attachment {} + +type BetterOmit< T, K extends PropertyKey > = { + [ P in keyof T as P extends K ? never : P ]: T[ P ]; +}; + +/** + * Transformed attachment object. + */ +export type Attachment = BetterOmit< + RestAttachment, + 'alt_text' | 'source_url' | 'caption' | 'title' +> & { + alt: WP_REST_API_Attachment[ 'alt_text' ]; + caption: WP_REST_API_Attachment[ 'caption' ][ 'raw' ] & string; + title: WP_REST_API_Attachment[ 'title' ][ 'raw' ]; + url: WP_REST_API_Attachment[ 'source_url' ]; + poster?: WP_REST_API_Attachment[ 'source_url' ]; +}; + +export type OnChangeHandler = ( attachments: Partial< Attachment >[] ) => void; +export type OnSuccessHandler = ( attachments: Partial< Attachment >[] ) => void; +export type OnErrorHandler = ( error: Error ) => void; + +export type CreateRestAttachment = Partial< RestAttachment >; + +export type AdditionalData = BetterOmit< CreateRestAttachment, 'meta' >; diff --git a/packages/media-utils/src/utils/upload-error.ts b/packages/media-utils/src/utils/upload-error.ts new file mode 100644 index 00000000000000..d712e9dcdb6966 --- /dev/null +++ b/packages/media-utils/src/utils/upload-error.ts @@ -0,0 +1,26 @@ +interface UploadErrorArgs { + code: string; + message: string; + file: File; + cause?: Error; +} + +/** + * MediaError class. + * + * Small wrapper around the `Error` class + * to hold an error code and a reference to a file object. + */ +export class UploadError extends Error { + code: string; + file: File; + + constructor( { code, message, file, cause }: UploadErrorArgs ) { + super( message, { cause } ); + + Object.setPrototypeOf( this, new.target.prototype ); + + this.code = code; + this.file = file; + } +} diff --git a/packages/media-utils/src/utils/upload-media.js b/packages/media-utils/src/utils/upload-media.js deleted file mode 100644 index e3c9b95d5c25cd..00000000000000 --- a/packages/media-utils/src/utils/upload-media.js +++ /dev/null @@ -1,232 +0,0 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; -import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; -import { __, sprintf } from '@wordpress/i18n'; - -const noop = () => {}; - -/** - * Browsers may use unexpected mime types, and they differ from browser to browser. - * This function computes a flexible array of mime types from the mime type structured provided by the server. - * Converts { jpg|jpeg|jpe: "image/jpeg" } into [ "image/jpeg", "image/jpg", "image/jpeg", "image/jpe" ] - * The computation of this array instead of directly using the object, - * solves the problem in chrome where mp3 files have audio/mp3 as mime type instead of audio/mpeg. - * https://bugs.chromium.org/p/chromium/issues/detail?id=227004 - * - * @param {?Object} wpMimeTypesObject Mime type object received from the server. - * Extensions are keys separated by '|' and values are mime types associated with an extension. - * - * @return {?Array} An array of mime types or the parameter passed if it was "falsy". - */ -export function getMimeTypesArray( wpMimeTypesObject ) { - if ( ! wpMimeTypesObject ) { - return wpMimeTypesObject; - } - return Object.entries( wpMimeTypesObject ) - .map( ( [ extensionsString, mime ] ) => { - const [ type ] = mime.split( '/' ); - const extensions = extensionsString.split( '|' ); - return [ - mime, - ...extensions.map( - ( extension ) => `${ type }/${ extension }` - ), - ]; - } ) - .flat(); -} - -/** - * Media Upload is used by audio, image, gallery, video, and file blocks to - * handle uploading a media file when a file upload button is activated. - * - * TODO: future enhancement to add an upload indicator. - * - * @param {Object} $0 Parameters object passed to the function. - * @param {?Array} $0.allowedTypes Array with the types of media that can be uploaded, if unset all types are allowed. - * @param {?Object} $0.additionalData Additional data to include in the request. - * @param {Array} $0.filesList List of files. - * @param {?number} $0.maxUploadFileSize Maximum upload size in bytes allowed for the site. - * @param {Function} $0.onError Function called when an error happens. - * @param {Function} $0.onFileChange Function called each time a file or a temporary representation of the file is available. - * @param {?Object} $0.wpAllowedMimeTypes List of allowed mime types and file extensions. - */ -export async function uploadMedia( { - allowedTypes, - additionalData = {}, - filesList, - maxUploadFileSize, - onError = noop, - onFileChange, - wpAllowedMimeTypes = null, -} ) { - // Cast filesList to array. - const files = [ ...filesList ]; - - const filesSet = []; - const setAndUpdateFiles = ( idx, value ) => { - revokeBlobURL( filesSet[ idx ]?.url ); - filesSet[ idx ] = value; - onFileChange( filesSet.filter( Boolean ) ); - }; - - // Allowed type specified by consumer. - const isAllowedType = ( fileType ) => { - if ( ! allowedTypes ) { - return true; - } - return allowedTypes.some( ( allowedType ) => { - // If a complete mimetype is specified verify if it matches exactly the mime type of the file. - if ( allowedType.includes( '/' ) ) { - return allowedType === fileType; - } - // Otherwise a general mime type is used and we should verify if the file mimetype starts with it. - return fileType.startsWith( `${ allowedType }/` ); - } ); - }; - - // Allowed types for the current WP_User. - const allowedMimeTypesForUser = getMimeTypesArray( wpAllowedMimeTypes ); - const isAllowedMimeTypeForUser = ( fileType ) => { - return allowedMimeTypesForUser.includes( fileType ); - }; - - const validFiles = []; - - for ( const mediaFile of files ) { - // Verify if user is allowed to upload this mime type. - // Defer to the server when type not detected. - if ( - allowedMimeTypesForUser && - mediaFile.type && - ! isAllowedMimeTypeForUser( mediaFile.type ) - ) { - onError( { - code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', - message: sprintf( - // translators: %s: file name. - __( - '%s: Sorry, you are not allowed to upload this file type.' - ), - mediaFile.name - ), - file: mediaFile, - } ); - continue; - } - - // Check if the block supports this mime type. - // Defer to the server when type not detected. - if ( mediaFile.type && ! isAllowedType( mediaFile.type ) ) { - onError( { - code: 'MIME_TYPE_NOT_SUPPORTED', - message: sprintf( - // translators: %s: file name. - __( '%s: Sorry, this file type is not supported here.' ), - mediaFile.name - ), - file: mediaFile, - } ); - continue; - } - - // Verify if file is greater than the maximum file upload size allowed for the site. - if ( maxUploadFileSize && mediaFile.size > maxUploadFileSize ) { - onError( { - code: 'SIZE_ABOVE_LIMIT', - message: sprintf( - // translators: %s: file name. - __( - '%s: This file exceeds the maximum upload size for this site.' - ), - mediaFile.name - ), - file: mediaFile, - } ); - continue; - } - - // Don't allow empty files to be uploaded. - if ( mediaFile.size <= 0 ) { - onError( { - code: 'EMPTY_FILE', - message: sprintf( - // translators: %s: file name. - __( '%s: This file is empty.' ), - mediaFile.name - ), - file: mediaFile, - } ); - continue; - } - - validFiles.push( mediaFile ); - - // Set temporary URL to create placeholder media file, this is replaced - // with final file from media gallery when upload is `done` below. - filesSet.push( { url: createBlobURL( mediaFile ) } ); - onFileChange( filesSet ); - } - - for ( let idx = 0; idx < validFiles.length; ++idx ) { - const mediaFile = validFiles[ idx ]; - try { - const savedMedia = await createMediaFromFile( - mediaFile, - additionalData - ); - // eslint-disable-next-line camelcase - const { alt_text, source_url, ...savedMediaProps } = savedMedia; - const mediaObject = { - ...savedMediaProps, - alt: savedMedia.alt_text, - caption: savedMedia.caption?.raw ?? '', - title: savedMedia.title.raw, - url: savedMedia.source_url, - }; - setAndUpdateFiles( idx, mediaObject ); - } catch ( error ) { - // Reset to empty on failure. - setAndUpdateFiles( idx, null ); - let message; - if ( error.message ) { - message = error.message; - } else { - message = sprintf( - // translators: %s: file name - __( 'Error while uploading file %s to the media library.' ), - mediaFile.name - ); - } - onError( { - code: 'GENERAL', - message, - file: mediaFile, - } ); - } - } -} - -/** - * @param {File} file Media File to Save. - * @param {?Object} additionalData Additional data to include in the request. - * - * @return {Promise} Media Object Promise. - */ -function createMediaFromFile( file, additionalData ) { - // Create upload payload. - const data = new window.FormData(); - data.append( 'file', file, file.name || file.type.replace( '/', '.' ) ); - if ( additionalData ) { - Object.entries( additionalData ).forEach( ( [ key, value ] ) => - data.append( key, value ) - ); - } - return apiFetch( { - path: '/wp/v2/media', - body: data, - method: 'POST', - } ); -} diff --git a/packages/media-utils/src/utils/upload-media.ts b/packages/media-utils/src/utils/upload-media.ts new file mode 100644 index 00000000000000..1bc861cfb3b607 --- /dev/null +++ b/packages/media-utils/src/utils/upload-media.ts @@ -0,0 +1,149 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; + +/** + * Internal dependencies + */ +import type { + AdditionalData, + Attachment, + OnChangeHandler, + OnErrorHandler, + OnSuccessHandler, +} from './types'; +import { uploadToServer } from './upload-to-server'; +import { validateMimeType } from './validate-mime-type'; +import { validateMimeTypeForUser } from './validate-mime-type-for-user'; +import { validateFileSize } from './validate-file-size'; +import { UploadError } from './upload-error'; + +interface UploadMediaArgs { + // Additional data to include in the request. + additionalData?: AdditionalData; + // Array with the types of media that can be uploaded, if unset all types are allowed. + allowedTypes?: string[]; + // List of files. + filesList: File[]; + // Maximum upload size in bytes allowed for the site. + maxUploadFileSize?: number; + // Function called when an error happens. + onError?: OnErrorHandler; + // Function called each time a file or a temporary representation of the file is available. + onFileChange?: OnChangeHandler; + // Function called once a file has completely finished uploading, including thumbnails. + onSuccess?: OnSuccessHandler; + // List of allowed mime types and file extensions. + wpAllowedMimeTypes?: Record< string, string > | null; + // Abort signal. + signal?: AbortSignal; +} + +/** + * Upload a media file when the file upload button is activated + * or when adding a file to the editor via drag & drop. + * + * @param $0 Parameters object passed to the function. + * @param $0.allowedTypes Array with the types of media that can be uploaded, if unset all types are allowed. + * @param $0.additionalData Additional data to include in the request. + * @param $0.filesList List of files. + * @param $0.maxUploadFileSize Maximum upload size in bytes allowed for the site. + * @param $0.onError Function called when an error happens. + * @param $0.onFileChange Function called each time a file or a temporary representation of the file is available. + * @param $0.wpAllowedMimeTypes List of allowed mime types and file extensions. + * @param $0.signal Abort signal. + */ +export function uploadMedia( { + wpAllowedMimeTypes, + allowedTypes, + additionalData = {}, + filesList, + maxUploadFileSize, + onError, + onFileChange, + signal, +}: UploadMediaArgs ) { + const validFiles = []; + + const filesSet: Array< Partial< Attachment > | null > = []; + const setAndUpdateFiles = ( index: number, value: Attachment | null ) => { + if ( filesSet[ index ]?.url ) { + revokeBlobURL( filesSet[ index ].url ); + } + filesSet[ index ] = value; + onFileChange?.( + filesSet.filter( ( attachment ) => attachment !== null ) + ); + }; + + for ( const mediaFile of filesList ) { + // Verify if user is allowed to upload this mime type. + // Defer to the server when type not detected. + try { + validateMimeTypeForUser( mediaFile, wpAllowedMimeTypes ); + } catch ( error: unknown ) { + onError?.( error as Error ); + continue; + } + + // Check if the caller (e.g. a block) supports this mime type. + // Defer to the server when type not detected. + try { + validateMimeType( mediaFile, allowedTypes ); + } catch ( error: unknown ) { + onError?.( error as Error ); + continue; + } + + // Verify if file is greater than the maximum file upload size allowed for the site. + try { + validateFileSize( mediaFile, maxUploadFileSize ); + } catch ( error: unknown ) { + onError?.( error as Error ); + continue; + } + + validFiles.push( mediaFile ); + + // Set temporary URL to create placeholder media file, this is replaced + // with final file from media gallery when upload is `done` below. + filesSet.push( { url: createBlobURL( mediaFile ) } ); + onFileChange?.( filesSet as Array< Partial< Attachment > > ); + } + + validFiles.map( async ( file, index ) => { + try { + const attachment = await uploadToServer( + file, + additionalData, + signal + ); + setAndUpdateFiles( index, attachment ); + } catch ( error ) { + // Reset to empty on failure. + setAndUpdateFiles( index, null ); + + let message; + if ( error instanceof Error ) { + message = error.message; + } else { + message = sprintf( + // translators: %s: file name + __( 'Error while uploading file %s to the media library.' ), + file.name + ); + } + + onError?.( + new UploadError( { + code: 'GENERAL', + message, + file, + cause: error instanceof Error ? error : undefined, + } ) + ); + } + } ); +} diff --git a/packages/media-utils/src/utils/upload-to-server.ts b/packages/media-utils/src/utils/upload-to-server.ts new file mode 100644 index 00000000000000..7aa4243d5ccfd4 --- /dev/null +++ b/packages/media-utils/src/utils/upload-to-server.ts @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { flattenFormData } from './flatten-form-data'; +import { transformAttachment } from './transform-attachment'; +import type { CreateRestAttachment, RestAttachment } from './types'; + +export async function uploadToServer( + file: File, + additionalData: CreateRestAttachment = {}, + signal?: AbortSignal +) { + // Create upload payload. + const data = new FormData(); + data.append( 'file', file, file.name || file.type.replace( '/', '.' ) ); + for ( const [ key, value ] of Object.entries( additionalData ) ) { + flattenFormData( + data, + key, + value as string | Record< string, string > | undefined + ); + } + + return transformAttachment( + await apiFetch< RestAttachment >( { + // This allows the video block to directly get a video's poster image. + path: '/wp/v2/media?_embed=wp:featuredmedia', + body: data, + method: 'POST', + signal, + } ) + ); +} diff --git a/packages/media-utils/src/utils/validate-file-size.ts b/packages/media-utils/src/utils/validate-file-size.ts new file mode 100644 index 00000000000000..cc34462b268dda --- /dev/null +++ b/packages/media-utils/src/utils/validate-file-size.ts @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { UploadError } from './upload-error'; + +/** + * Verifies whether the file is within the file upload size limits for the site. + * + * @param file File object. + * @param maxUploadFileSize Maximum upload size in bytes allowed for the site. + */ +export function validateFileSize( file: File, maxUploadFileSize?: number ) { + // Don't allow empty files to be uploaded. + if ( file.size <= 0 ) { + throw new UploadError( { + code: 'EMPTY_FILE', + message: sprintf( + // translators: %s: file name. + __( '%s: This file is empty.' ), + file.name + ), + file, + } ); + } + + if ( maxUploadFileSize && file.size > maxUploadFileSize ) { + throw new UploadError( { + code: 'SIZE_ABOVE_LIMIT', + message: sprintf( + // translators: %s: file name. + __( + '%s: This file exceeds the maximum upload size for this site.' + ), + file.name + ), + file, + } ); + } +} diff --git a/packages/media-utils/src/utils/validate-mime-type-for-user.ts b/packages/media-utils/src/utils/validate-mime-type-for-user.ts new file mode 100644 index 00000000000000..858c583561978e --- /dev/null +++ b/packages/media-utils/src/utils/validate-mime-type-for-user.ts @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { UploadError } from './upload-error'; +import { getMimeTypesArray } from './get-mime-types-array'; + +/** + * Verifies if the user is allowed to upload this mime type. + * + * @param file File object. + * @param wpAllowedMimeTypes List of allowed mime types and file extensions. + */ +export function validateMimeTypeForUser( + file: File, + wpAllowedMimeTypes?: Record< string, string > | null +) { + // Allowed types for the current WP_User. + const allowedMimeTypesForUser = getMimeTypesArray( wpAllowedMimeTypes ); + + if ( ! allowedMimeTypesForUser ) { + return; + } + + const isAllowedMimeTypeForUser = allowedMimeTypesForUser.includes( + file.type + ); + + if ( file.type && ! isAllowedMimeTypeForUser ) { + throw new UploadError( { + code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', + message: sprintf( + // translators: %s: file name. + __( + '%s: Sorry, you are not allowed to upload this file type.' + ), + file.name + ), + file, + } ); + } +} diff --git a/packages/media-utils/src/utils/validate-mime-type.ts b/packages/media-utils/src/utils/validate-mime-type.ts new file mode 100644 index 00000000000000..2d99455d7b60f1 --- /dev/null +++ b/packages/media-utils/src/utils/validate-mime-type.ts @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { UploadError } from './upload-error'; + +/** + * Verifies if the caller (e.g. a block) supports this mime type. + * + * @param file File object. + * @param allowedTypes List of allowed mime types. + */ +export function validateMimeType( file: File, allowedTypes?: string[] ) { + if ( ! allowedTypes ) { + return; + } + + // Allowed type specified by consumer. + const isAllowedType = allowedTypes.some( ( allowedType ) => { + // If a complete mimetype is specified verify if it matches exactly the mime type of the file. + if ( allowedType.includes( '/' ) ) { + return allowedType === file.type; + } + // Otherwise a general mime type is used, and we should verify if the file mimetype starts with it. + return file.type.startsWith( `${ allowedType }/` ); + } ); + + if ( file.type && ! isAllowedType ) { + throw new UploadError( { + code: 'MIME_TYPE_NOT_SUPPORTED', + message: sprintf( + // translators: %s: file name. + __( '%s: Sorry, this file type is not supported here.' ), + file.name + ), + file, + } ); + } +} diff --git a/packages/media-utils/tsconfig.json b/packages/media-utils/tsconfig.json new file mode 100644 index 00000000000000..8559f1507b7235 --- /dev/null +++ b/packages/media-utils/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "types": [ "gutenberg-env" ], + "checkJs": false + }, + "include": [ "src/**/*" ], + "references": [ + { "path": "../api-fetch" }, + { "path": "../blob" }, + { "path": "../element" }, + { "path": "../i18n" } + ] +} diff --git a/tsconfig.json b/tsconfig.json index cf986ddbee72bf..6be31e9b61bef8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,7 @@ { "path": "packages/is-shallow-equal" }, { "path": "packages/keycodes" }, { "path": "packages/lazy-import" }, + { "path": "packages/media-utils" }, { "path": "packages/notices" }, { "path": "packages/plugins" }, { "path": "packages/prettier-config" },