diff --git a/packages/core/src/asset_manager/view/FileUploader.ts b/packages/core/src/asset_manager/view/FileUploader.ts index 208298333f..900a19113e 100644 --- a/packages/core/src/asset_manager/view/FileUploader.ts +++ b/packages/core/src/asset_manager/view/FileUploader.ts @@ -6,6 +6,26 @@ import html from '../../utils/html'; import { AssetManagerConfig } from '../config/config'; import { UploadFileClb, UploadFileOptions } from '../types'; +/** + * Check if a file matches an `accept` attribute value. + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept + * Allows everything when empty or a bare wildcard; otherwise supports MIME + * wildcards (`image/*`), exact MIME (`image/png`), and extensions (`.png`). + */ +export function isFileAccepted(file: File, accept?: string | null): boolean { + const acc = (accept || '').trim(); + if (!acc || acc === '*' || acc === '*/*') return true; + const type = (file.type || '').toLowerCase(); + const name = (file.name || '').toLowerCase(); + return acc.split(',').some((raw) => { + const token = raw.trim().toLowerCase(); + if (!token) return false; + if (token.startsWith('.')) return name.endsWith(token); // extension + if (token.endsWith('/*')) return type.startsWith(token.slice(0, -1)); // e.g. "image/" + return type === token; // exact MIME + }); +} + type FileUploaderTemplateProps = { pfx: string; title: string; @@ -141,7 +161,14 @@ export default class FileUploaderView extends View { * */ uploadFile(e: DragEvent, clb?: UploadFileClb, opts?: UploadFileOptions) { opts; // Options are not used here but can be used by the custom uploadFile function - const files = e.dataTransfer ? e.dataTransfer.files : ((e.target as any)?.files as FileList); + const allFiles = e.dataTransfer ? e.dataTransfer.files : ((e.target as any)?.files as FileList); + // #6032: the `accept` attribute is not enforced by browsers on drag-drop, so filter here. + const accept = this.$el.find('input[type=file]').attr('accept'); + const files = Array.prototype.filter.call(allFiles || [], (f: File) => isFileAccepted(f, accept)) as File[]; + + // All dropped files rejected (e.g. a video on an image upload) -> do nothing. + if (allFiles && allFiles.length && !files.length) return; + const { config } = this; const { beforeUpload } = config; diff --git a/packages/core/test/specs/asset_manager/view/FileUploader.ts b/packages/core/test/specs/asset_manager/view/FileUploader.ts index 0131b002e2..47eed11b9e 100644 --- a/packages/core/test/specs/asset_manager/view/FileUploader.ts +++ b/packages/core/test/specs/asset_manager/view/FileUploader.ts @@ -1,4 +1,26 @@ -import FileUploader from '../../../../src/asset_manager/view/FileUploader'; +import FileUploader, { isFileAccepted } from '../../../../src/asset_manager/view/FileUploader'; + +const f = (type: string, name = 'x') => ({ type, name }) as File; + +describe('isFileAccepted (#6032)', () => { + test('allows everything when accept is empty or */*', () => { + expect(isFileAccepted(f('video/mp4', 'a.mp4'), '')).toBe(true); + expect(isFileAccepted(f('video/mp4', 'a.mp4'), '*/*')).toBe(true); + expect(isFileAccepted(f('video/mp4', 'a.mp4'), undefined)).toBe(true); + }); + test('matches MIME wildcard (image/*)', () => { + expect(isFileAccepted(f('image/png', 'a.png'), 'image/*')).toBe(true); + expect(isFileAccepted(f('video/mp4', 'a.mp4'), 'image/*')).toBe(false); + }); + test('matches exact MIME and comma lists', () => { + expect(isFileAccepted(f('image/png'), 'image/png,image/jpeg')).toBe(true); + expect(isFileAccepted(f('image/gif'), 'image/png,image/jpeg')).toBe(false); + }); + test('matches file extensions', () => { + expect(isFileAccepted(f('', 'photo.PNG'), '.png')).toBe(true); + expect(isFileAccepted(f('', 'clip.mp4'), '.png,.jpg')).toBe(false); + }); +}); describe('File Uploader', () => { let obj: FileUploader; @@ -72,4 +94,31 @@ describe('File Uploader', () => { expect(view.uploadFile).toEqual(FileUploader.embedAsBase64); }); }); + + describe('Drag-drop respects accept (#6032)', () => { + const makeView = () => { + const customFetch = jest.fn(() => Promise.resolve('[]')); + // headers:{} is REQUIRED: direct construction does NOT merge the module's config + // defaults, and uploadFile reads `config.headers` before fetching - a missing + // headers object throws before customFetch is ever reached. + const view = new FileUploader({ config: { upload: 'http://localhost/up', headers: {}, customFetch } }); + document.body.innerHTML = '
'; + document.body.querySelector('#fx')!.appendChild(view.render().el); + view.$el.find('input[type=file]').attr('accept', 'image/*'); // what OpenAssets sets for images + return { view, customFetch }; + }; + const drop = (view: FileUploader, file: File) => + view.uploadFile({ dataTransfer: { files: [file] }, preventDefault() {} } as any); + + test('rejects a dropped video (no upload triggered)', () => { + const { view, customFetch } = makeView(); + drop(view, new File(['x'], 'clip.mp4', { type: 'video/mp4' })); + expect(customFetch).not.toHaveBeenCalled(); + }); + test('still uploads a dropped image', () => { + const { view, customFetch } = makeView(); + drop(view, new File(['x'], 'pic.png', { type: 'image/png' })); + expect(customFetch).toHaveBeenCalledTimes(1); + }); + }); });