Browse Source

fix(asset-manager): enforce accept filter on drag-and-dropped files (#6778)

Browsers don't apply the input `accept` attribute to dropped files, so
videos dropped on an image upload zone were uploaded and selectable.
Filter dropped files against `accept` in FileUploader.uploadFile().

Closes #6032
release-v0.23.3
Geon 2 months ago
committed by GitHub
parent
commit
f92b2b2603
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 29
      packages/core/src/asset_manager/view/FileUploader.ts
  2. 51
      packages/core/test/specs/asset_manager/view/FileUploader.ts

29
packages/core/src/asset_manager/view/FileUploader.ts

@ -6,6 +6,26 @@ import html from '../../utils/html';
import { AssetManagerConfig } from '../config/config'; import { AssetManagerConfig } from '../config/config';
import { UploadFileClb, UploadFileOptions } from '../types'; 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 = { type FileUploaderTemplateProps = {
pfx: string; pfx: string;
title: string; title: string;
@ -141,7 +161,14 @@ export default class FileUploaderView extends View {
* */ * */
uploadFile(e: DragEvent, clb?: UploadFileClb, opts?: UploadFileOptions) { uploadFile(e: DragEvent, clb?: UploadFileClb, opts?: UploadFileOptions) {
opts; // Options are not used here but can be used by the custom uploadFile function 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 { config } = this;
const { beforeUpload } = config; const { beforeUpload } = config;

51
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', () => { describe('File Uploader', () => {
let obj: FileUploader; let obj: FileUploader;
@ -72,4 +94,31 @@ describe('File Uploader', () => {
expect(view.uploadFile).toEqual(FileUploader.embedAsBase64); 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 = '<div id="fx"></div>';
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);
});
});
}); });

Loading…
Cancel
Save