diff --git a/README.md b/README.md index 05f8b8c..d4d7b07 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ If your form rendering is customized, start with 19. [Repeated fields](src/Resources/doc/3_19.md) 20. [One form rendered several times](src/Resources/doc/3_20.md) 21. [Validation events](src/Resources/doc/3_21.md) +22. [File uploads](src/Resources/doc/3_22.md) ## Development diff --git a/Tests/Unit/JsFormValidatorFactoryTest.php b/Tests/Unit/JsFormValidatorFactoryTest.php index bf8c4be..752d8e3 100644 --- a/Tests/Unit/JsFormValidatorFactoryTest.php +++ b/Tests/Unit/JsFormValidatorFactoryTest.php @@ -13,6 +13,7 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; @@ -630,6 +631,35 @@ public function testTheTrimOptionOfTheElementIsExported() $this->assertTrue($model->children['trimmed']->trim); } + public function testFileConstraintExportsItsNormalizedMaxSize() + { + $factory = $this->createFactory(); + $formFactory = $this->createFormFactory($factory); + $form = $formFactory + ->createNamedBuilder('profile', FormType::class) + ->add('avatar', FileType::class, array( + 'constraints' => array( + new Assert\File(maxSize: '2Mi', mimeTypes: array('image/png')), + ), + )) + ->getForm() + ; + + $model = $factory->createJsModel($form); + + // "maxSize" is protected behind a magic getter, so it is not part of + // the generic public property export of the model + $this->assertStringContainsString("'maxSize':2097152", (string) $model); + // The suffix of the option decides how the sizes are displayed + $this->assertStringContainsString("'binaryFormat':true", (string) $model); + + $options = $model->children['avatar']->data['form']['constraints'][Assert\File::class][0]; + + $this->assertSame(2097152, $options['maxSize']); + $this->assertTrue($options['binaryFormat']); + $this->assertSame(array('image/png'), $options['mimeTypes']); + } + public function testTransformersOfOtherTypesAreNotGivenNumberParams() { $factory = new TestableJsFormValidatorFactory( diff --git a/src/Factory/JsFormValidatorFactory.php b/src/Factory/JsFormValidatorFactory.php index a8db1af..3aa9292 100644 --- a/src/Factory/JsFormValidatorFactory.php +++ b/src/Factory/JsFormValidatorFactory.php @@ -17,6 +17,7 @@ use Symfony\Component\Form\FormInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\File; use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Mapping\ClassMetadataInterface; use Symfony\Component\Validator\Mapping\GetterMetadata; @@ -760,7 +761,12 @@ protected function parseConstraints(array $constraints) $item = $this->resolveRangeDateBounds($item); } - $result[get_class($item)][] = $item; + // The "maxSize" option of the File constraint is a protected + // property behind a magic getter, so the generic object export of + // the model cannot see it. Export the options as a plain list + $result[get_class($item)][] = $item instanceof File + ? array('maxSize' => $item->maxSize) + get_object_vars($item) + : $item; } return $result; diff --git a/src/Resources/doc/3_22.md b/src/Resources/doc/3_22.md new file mode 100644 index 0000000..4570ed9 --- /dev/null +++ b/src/Resources/doc/3_22.md @@ -0,0 +1,76 @@ +### 3.22 File uploads + +The `File` constraint is validated in the browser as far as the File API +allows. A file that is too large or has an unexpected mime type is reported +before the upload starts instead of after a full round trip to the server: + +```php +add('avatar', FileType::class, [ + 'constraints' => [ + new Assert\File( + maxSize: '2M', + mimeTypes: ['image/png', 'image/jpeg'], + ), + ], + ]); + } +} +``` + +#### What is checked in the browser + +| Option | Checked with | +|---------------------|-------------------------------------------------------| +| `maxSize` | `File.size` | +| `mimeTypes` | `File.type`, including the `type/*` wildcards | +| `extensions` | the extension of `File.name` | +| `filenameMaxLength` | the length of `File.name` in `filenameCountUnit` units | +| empty file | a `File.size` of zero | + +The sizes are formatted the same way `FileValidator` formats them, so +`maxSizeMessage`, `mimeTypesMessage`, `extensionsMessage`, +`disallowEmptyMessage` and `filenameTooLongMessage` read exactly as they do in +the server-side error. + +A `NotBlank` constraint on the same field keeps working: the value of a file +input is the list of selected files, so an empty list is an empty value. + +#### What stays on the server + +The browser cannot see the content of a file, only what the File API exposes, +so these checks only happen after the form is submitted: + +- `File.type` is sniffed by the browser and is not always right. When the + browser reports no type at all, the mime type check is skipped and the + server has the last word. +- `extensions` narrows the accepted mime types through the media type database + of the `symfony/mime` component. In the browser only the extension itself is + checked, the derived mime types are not. +- `filenameCharset`, `notFoundMessage`, `notReadableMessage` and every + `upload*ErrorMessage` describe conditions that only exist server side. + +Client-side validation is a convenience, never a replacement: the server +validates the upload again in every case. + +#### Images + +`Image` extends `File` in PHP, but its own options - `maxWidth`, `minWidth`, +`maxHeight`, `minHeight`, `maxRatio`, `minRatio`, `allowSquare` and the rest - +need the image to be decoded first, which the browser can only do +asynchronously. They are not implemented, so an `Image` constraint is skipped +in the browser and validated on the server only. Add a `File` constraint next +to it to get the size and mime type checked before the upload. diff --git a/src/Resources/public/js/SvarohJsFormValidator.js b/src/Resources/public/js/SvarohJsFormValidator.js index 0a69fce..5dc6edc 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.js +++ b/src/Resources/public/js/SvarohJsFormValidator.js @@ -1237,6 +1237,8 @@ var SvarohJsFormValidator = new function () { || 'Symfony\\Component\\Form\\Extension\\Core\\Type\\RadioType' == element.type ) { value = element.domNode.checked; + } else if ('file' === String(element.domNode.type || '').toLowerCase()) { + value = this.getFileListValue(element); } else if ('select' === element.domNode.tagName.toLowerCase()) { value = []; var field = element.domNode; @@ -1253,6 +1255,29 @@ var SvarohJsFormValidator = new function () { return value; }; + /** + * The "value" of a file input only holds a fake path, the selected files + * live in its "files" list. Symfony maps the field to uploaded file + * objects, so the browser works with the File objects as well. + * + * @param {SvarohJsFormElement} element + * + * @return {*} + */ + this.getFileListValue = function (element) { + var files = element.domNode.files; + if (!files) { + return this.getInputValue(element); + } + + var value = []; + for (var i = 0; i < files.length; i++) { + value.push(files[i]); + } + + return value; + }; + /** * @param {Object} list */ diff --git a/src/Resources/public/js/SvarohJsFormValidator.test.js b/src/Resources/public/js/SvarohJsFormValidator.test.js index 0acb5ee..c36ba02 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.test.js +++ b/src/Resources/public/js/SvarohJsFormValidator.test.js @@ -893,6 +893,30 @@ describe('SvarohJsFormValidator runtime helpers', () => { expect(window.SvarohJsFormValidator.getElementValue(mapped)).toBe('child-value'); }); + test('extracts the selected files of a file input instead of its fake path', () => { + const file = new File(['abc'], 'avatar.png', { type: 'image/png' }); + const element = new window.SvarohJsFormElement(); + element.domNode = { + tagName: 'input', + type: 'file', + value: 'C:\\fakepath\\avatar.png', + files: [file], + }; + + expect(window.SvarohJsFormValidator.getElementValue(element)).toEqual([file]); + + // An empty file list stays an empty value, so NotBlank keeps working + element.domNode.files = []; + expect(window.SvarohJsFormValidator.getElementValue(element)).toEqual([]); + expect(window.SvarohJsFormValidator.isValueEmty( + window.SvarohJsFormValidator.getElementValue(element) + )).toBe(true); + + // Without the File API there is nothing but the fake path to read + delete element.domNode.files; + expect(window.SvarohJsFormValidator.getElementValue(element)).toBe('C:\\fakepath\\avatar.png'); + }); + test('finds DOM nodes and forms through ids, names, and descendants', () => { document.body.innerHTML = '
'; const named = window.SvarohJsFormValidator.findDomElement({ diff --git a/src/Resources/public/js/constraints/File.js b/src/Resources/public/js/constraints/File.js new file mode 100644 index 0000000..e2d52a0 --- /dev/null +++ b/src/Resources/public/js/constraints/File.js @@ -0,0 +1,377 @@ +var KB_BYTES = 1000; +var MB_BYTES = 1000000; +var KIB_BYTES = 1024; +var MIB_BYTES = 1048576; + +var SUFFICES = { + 1: 'bytes', + 1000: 'kB', + 1000000: 'MB', + 1024: 'KiB', + 1048576: 'MiB' +}; + +/** + * @param {Number} value + * @param {Number} precision + * + * @return {Number} + */ +function round(value, precision) { + var factor = Math.pow(10, precision); + + return Math.round(value * factor) / factor; +} + +/** + * @param {String} value + * @param {Number} numberOfDecimals + * + * @return {Boolean} + */ +function moreDecimalsThan(value, numberOfDecimals) { + return value.length > String(round(Number(value), numberOfDecimals)).length; +} + +/** + * Convert the limit to the smallest possible number the same way + * Symfony\Component\Validator\Constraints\FileValidator does + * + * @param {Number} size + * @param {Number} limit + * @param {Boolean} binaryFormat + * + * @return {Array} the size, the limit and the suffix they are expressed in + */ +function factorizeSizes(size, limit, binaryFormat) { + var coef = binaryFormat ? MIB_BYTES : MB_BYTES; + var coefFactor = binaryFormat ? KIB_BYTES : KB_BYTES; + + // A limit below the coefficient would be displayed as a value lower than + // one, so keep on factorizing + while (limit < coef) { + coef /= coefFactor; + } + + var limitAsString = String(limit / coef); + + // Restrict the limit to 2 decimals, without rounding it + while (coef > 1 && moreDecimalsThan(limitAsString, 2)) { + coef /= coefFactor; + limitAsString = String(limit / coef); + } + + var sizeAsString = String(round(size / coef, 2)); + + // If rounding makes the size and the limit look the same, reduce the + // coefficient + while (coef > 1 && sizeAsString === limitAsString) { + coef /= coefFactor; + limitAsString = String(limit / coef); + sizeAsString = String(round(size / coef, 2)); + } + + return [sizeAsString, limitAsString, SUFFICES[coef]]; +} + +/** + * @param {Array} values + * + * @return {String} + */ +function formatValues(values) { + var formatted = []; + for (var i = 0; i < values.length; i++) { + formatted.push(SvarohJsBaseConstraint.formatValue(values[i])); + } + + return formatted.join(', '); +} + +//noinspection JSUnusedGlobalSymbols +/** + * Checks the selected files against the options a browser can evaluate + * + * The File API only exposes the name, the size and a sniffed mime type of a + * file. Everything that needs the real content of the file - the mime type of + * an unknown extension, the readability of a path, the upload errors - stays + * on the server side and is reported after the form is submitted. + * + * @constructor + */ +export default function SymfonyComponentValidatorConstraintsFile() { + this.maxSize = null; + this.binaryFormat = false; + this.mimeTypes = []; + this.extensions = []; + this.filenameMaxLength = null; + this.filenameCountUnit = 'bytes'; + + this.maxSizeMessage = ''; + this.mimeTypesMessage = ''; + this.extensionsMessage = ''; + this.disallowEmptyMessage = ''; + this.filenameTooLongMessage = ''; + + this.validate = function (value) { + var errors = []; + var files = this.getFiles(value); + + for (var i = 0; i < files.length; i++) { + errors = errors.concat(this.validateFile(files[i])); + } + + return errors; + }; + + /** + * @param {File} file + * + * @return {Array} + */ + this.validateFile = function (file) { + var name = SvarohJsBaseConstraint.formatValue(file.name); + var errors = []; + + if (this.filenameMaxLength && this.filenameMaxLength < this.getFilenameLength(file.name)) { + return [SvarohJsBaseConstraint.prepareMessage( + this.filenameTooLongMessage, + {'{{ filename_max_length }}': SvarohJsBaseConstraint.formatValue(this.filenameMaxLength)}, + this.filenameMaxLength + )]; + } + + if (!file.size) { + return [SvarohJsBaseConstraint.prepareMessage( + this.disallowEmptyMessage, + {'{{ file }}': name, '{{ name }}': name} + )]; + } + + if (this.maxSize && file.size > this.maxSize) { + var sizes = factorizeSizes(file.size, this.maxSize, this.binaryFormat); + + return [SvarohJsBaseConstraint.prepareMessage(this.maxSizeMessage, { + '{{ file }}': name, + '{{ size }}': sizes[0], + '{{ limit }}': sizes[1], + '{{ suffix }}': sizes[2], + '{{ name }}': name + })]; + } + + // Symfony reports an unexpected extension and still checks the mime + // type afterwards + var extensions = this.getExtensions(); + if (extensions.length && -1 === extensions.indexOf(this.getFileExtension(file.name))) { + errors.push(SvarohJsBaseConstraint.prepareMessage(this.extensionsMessage, { + '{{ file }}': name, + '{{ extension }}': SvarohJsBaseConstraint.formatValue(this.getFileExtension(file.name)), + '{{ extensions }}': formatValues(extensions), + '{{ name }}': name + })); + } + + var mimeTypes = this.getMimeTypes(); + // An empty type means the browser could not sniff one, the server has + // the last word on it anyway + if (mimeTypes.length && file.type && !this.matchesMimeTypes(file.type, mimeTypes)) { + errors.push(SvarohJsBaseConstraint.prepareMessage(this.mimeTypesMessage, { + '{{ file }}': name, + '{{ type }}': SvarohJsBaseConstraint.formatValue(file.type), + '{{ types }}': formatValues(mimeTypes), + '{{ name }}': name + })); + } + + return errors; + }; + + /** + * Only the values the File API describes can be validated here + * + * @param {*} value + * + * @return {Array} + */ + this.getFiles = function (value) { + if (this.isFile(value)) { + return [value]; + } + + var files = []; + if (value instanceof Array) { + for (var i = 0; i < value.length; i++) { + if (this.isFile(value[i])) { + files.push(value[i]); + } + } + } + + return files; + }; + + /** + * @param {*} value + * + * @return {Boolean} + */ + this.isFile = function (value) { + return null !== value + && 'object' == typeof value + && 'number' == typeof value.size + && 'string' == typeof value.name; + }; + + /** + * @param {String} mime + * @param {Array} mimeTypes + * + * @return {Boolean} + */ + this.matchesMimeTypes = function (mime, mimeTypes) { + var discrete = mime.substring(0, mime.indexOf('/')); + + for (var i = 0; i < mimeTypes.length; i++) { + if (mimeTypes[i] === mime) { + return true; + } + // A "type/*" mime type accepts every subtype of that type + var wildcard = mimeTypes[i].indexOf('/*'); + if (wildcard > 0 && discrete === mimeTypes[i].substring(0, wildcard)) { + return true; + } + } + + return false; + }; + + /** + * @param {String} filename + * + * @return {String} + */ + this.getFileExtension = function (filename) { + var dot = filename.lastIndexOf('.'); + + return -1 === dot ? '' : filename.substring(dot + 1).toLowerCase(); + }; + + /** + * The option holds either a list of extensions or a map of extensions to + * the mime types they allow, the same way the PHP option does + * + * @return {Array} + */ + this.getExtensions = function () { + var extensions = []; + for (var key in this.extensions) { + if (this.extensions.hasOwnProperty(key)) { + // A numeric key means the extension is stored as the value + extensions.push(String(parseInt(key, 10)) === String(key) ? this.extensions[key] : key); + } + } + + return extensions; + }; + + /** + * @return {Array} + */ + this.getMimeTypes = function () { + if ('string' == typeof this.mimeTypes) { + return [this.mimeTypes]; + } + + return this.mimeTypes instanceof Array ? this.mimeTypes : []; + }; + + /** + * @param {String} filename + * + * @return {Number} + */ + this.getFilenameLength = function (filename) { + if ('codepoints' === this.filenameCountUnit) { + return this.countCodePoints(filename); + } + if ('graphemes' === this.filenameCountUnit) { + return this.countGraphemes(filename); + } + + return this.countBytes(filename); + }; + + /** + * Length of the UTF-8 representation of the string + * + * @param {String} value + * + * @return {Number} + */ + this.countBytes = function (value) { + var bytes = 0; + for (var i = 0; i < value.length; i++) { + var code = value.charCodeAt(i); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xD800 && code <= 0xDBFF && i + 1 < value.length) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } + + return bytes; + }; + + /** + * @param {String} value + * + * @return {Number} + */ + this.countCodePoints = function (value) { + var count = 0; + for (var i = 0; i < value.length; i++) { + var code = value.charCodeAt(i); + if (code >= 0xD800 && code <= 0xDBFF && i + 1 < value.length) { + i++; + } + count++; + } + + return count; + }; + + /** + * Falls back to the code point count where the browser cannot segment + * graphemes + * + * @param {String} value + * + * @return {Number} + */ + this.countGraphemes = function (value) { + if ('undefined' == typeof Intl || 'function' != typeof Intl.Segmenter) { + return this.countCodePoints(value); + } + + return Array.from(new Intl.Segmenter(undefined, {granularity: 'grapheme'}).segment(value)).length; + }; + + this.onCreate = function () { + this.maxSize = parseInt(this.maxSize, 10); + if (isNaN(this.maxSize)) { + this.maxSize = null; + } + this.filenameMaxLength = parseInt(this.filenameMaxLength, 10); + if (isNaN(this.filenameMaxLength)) { + this.filenameMaxLength = null; + } + } +} + +window.SymfonyComponentValidatorConstraintsFile = SymfonyComponentValidatorConstraintsFile; diff --git a/src/Resources/public/js/constraints/File.test.js b/src/Resources/public/js/constraints/File.test.js new file mode 100644 index 0000000..1a86b3a --- /dev/null +++ b/src/Resources/public/js/constraints/File.test.js @@ -0,0 +1,230 @@ +import '../SvarohJsFormValidator'; +import SymfonyComponentValidatorConstraintsFile from './File'; + +// The default messages of Symfony\Component\Validator\Constraints\File +const MAX_SIZE_MESSAGE = 'The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.'; +const MIME_TYPES_MESSAGE = 'The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.'; +const EXTENSIONS_MESSAGE = 'The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}.'; +const DISALLOW_EMPTY_MESSAGE = 'An empty file is not allowed.'; +const FILENAME_TOO_LONG_MESSAGE = 'The filename is too long. It should have {{ filename_max_length }} character or less.' + + '|The filename is too long. It should have {{ filename_max_length }} characters or less.'; + +function createConstraint(options) { + const constraint = new SymfonyComponentValidatorConstraintsFile(); + constraint.maxSizeMessage = MAX_SIZE_MESSAGE; + constraint.mimeTypesMessage = MIME_TYPES_MESSAGE; + constraint.extensionsMessage = EXTENSIONS_MESSAGE; + constraint.disallowEmptyMessage = DISALLOW_EMPTY_MESSAGE; + constraint.filenameTooLongMessage = FILENAME_TOO_LONG_MESSAGE; + + for (const name in options) { + constraint[name] = options[name]; + } + constraint.onCreate(); + + return constraint; +} + +function createFile(name, size, type) { + return { name, size, type: type || '' }; +} + +describe('SymfonyComponentValidatorConstraintsFile size', () => { + test.each([ + // size, maxSize, binaryFormat, expected message + [3000000, 2000000, false, 'The file is too large (3 MB). Allowed maximum size is 2 MB.'], + [3145728, 2097152, true, 'The file is too large (3 MiB). Allowed maximum size is 2 MiB.'], + [2000, 1500, false, 'The file is too large (2 kB). Allowed maximum size is 1.5 kB.'], + [250, 100, false, 'The file is too large (250 bytes). Allowed maximum size is 100 bytes.'], + // A limit that needs more than 2 decimals is factorized further down + [2000000, 1234567, false, 'The file is too large (2000000 bytes). Allowed maximum size is 1234567 bytes.'], + // A size that rounds to the limit is factorized further down as well + [2004000, 2000000, false, 'The file is too large (2004 kB). Allowed maximum size is 2000 kB.'], + ])( + 'reports a file of %s bytes over a limit of %s', + (size, maxSize, binaryFormat, expected) => { + const constraint = createConstraint({ maxSize, binaryFormat }); + + expect(constraint.validate([createFile('report.pdf', size)])).toStrictEqual([expected]); + }, + ); + + test('accepts a file within the limit', () => { + const constraint = createConstraint({ maxSize: 2000000 }); + + expect(constraint.validate([createFile('report.pdf', 2000000)])).toStrictEqual([]); + }); + + test('reports an empty file before any other check', () => { + const constraint = createConstraint({ maxSize: 10, mimeTypes: ['image/png'] }); + + expect(constraint.validate([createFile('empty.txt', 0, 'text/plain')])) + .toStrictEqual([DISALLOW_EMPTY_MESSAGE]); + }); + + test('does not check the size when no limit is configured', () => { + const constraint = createConstraint({}); + + expect(constraint.validate([createFile('report.pdf', 999999999)])).toStrictEqual([]); + }); + + test('onCreate turns an unusable limit into no limit at all', () => { + const constraint = createConstraint({ maxSize: null, filenameMaxLength: null }); + + expect(constraint.maxSize).toBeNull(); + expect(constraint.filenameMaxLength).toBeNull(); + }); +}); + +describe('SymfonyComponentValidatorConstraintsFile mime types', () => { + test('reports a mime type outside of the allowed list', () => { + const constraint = createConstraint({ mimeTypes: ['image/png', 'image/jpeg'] }); + + expect(constraint.validate([createFile('avatar.gif', 10, 'image/gif')])).toStrictEqual([ + 'The mime type of the file is invalid ("image/gif"). Allowed mime types are "image/png", "image/jpeg".', + ]); + }); + + test('accepts a mime type matched by a wildcard', () => { + const constraint = createConstraint({ mimeTypes: ['image/*'] }); + + expect(constraint.validate([createFile('avatar.gif', 10, 'image/gif')])).toStrictEqual([]); + expect(constraint.validate([createFile('notes.txt', 10, 'text/plain')])).toStrictEqual([ + 'The mime type of the file is invalid ("text/plain"). Allowed mime types are "image/*".', + ]); + }); + + test('accepts a single mime type given as a string', () => { + const constraint = createConstraint({ mimeTypes: 'application/pdf' }); + + expect(constraint.validate([createFile('report.pdf', 10, 'application/pdf')])).toStrictEqual([]); + }); + + test('leaves a file the browser could not sniff to the server', () => { + const constraint = createConstraint({ mimeTypes: ['image/png'] }); + + expect(constraint.validate([createFile('avatar.png', 10, '')])).toStrictEqual([]); + }); +}); + +describe('SymfonyComponentValidatorConstraintsFile extensions', () => { + test('reports an extension outside of the allowed list', () => { + const constraint = createConstraint({ extensions: ['png', 'jpg'] }); + + expect(constraint.validate([createFile('avatar.gif', 10)])).toStrictEqual([ + 'The extension of the file is invalid ("gif"). Allowed extensions are "png", "jpg".', + ]); + }); + + test('reads the extensions from the keys of a mime type map', () => { + const constraint = createConstraint({ extensions: { png: ['image/png'] } }); + + expect(constraint.validate([createFile('avatar.PNG', 10)])).toStrictEqual([]); + expect(constraint.validate([createFile('avatar', 10)])).toStrictEqual([ + 'The extension of the file is invalid (""). Allowed extensions are "png".', + ]); + }); + + test('reports the extension and the mime type of the same file', () => { + const constraint = createConstraint({ extensions: ['png'], mimeTypes: ['image/png'] }); + + expect(constraint.validate([createFile('avatar.gif', 10, 'image/gif')])).toStrictEqual([ + 'The extension of the file is invalid ("gif"). Allowed extensions are "png".', + 'The mime type of the file is invalid ("image/gif"). Allowed mime types are "image/png".', + ]); + }); +}); + +describe('SymfonyComponentValidatorConstraintsFile filename length', () => { + test.each([ + // filenameCountUnit, filename, expected length + ['bytes', 'aé.png', 7], + ['codepoints', 'aé.png', 6], + ['bytes', '文.png', 7], + ['codepoints', '文.png', 5], + ['bytes', '\u{1F600}.png', 8], + ['codepoints', '\u{1F600}.png', 5], + ['graphemes', '\u{1F600}.png', 5], + ])( + 'counts a filename in %s', + (filenameCountUnit, filename, expected) => { + const constraint = createConstraint({ filenameCountUnit }); + + expect(constraint.getFilenameLength(filename)).toBe(expected); + }, + ); + + test('falls back to code points where graphemes cannot be segmented', () => { + const constraint = createConstraint({ filenameCountUnit: 'graphemes' }); + const segmenter = Intl.Segmenter; + delete Intl.Segmenter; + + try { + expect(constraint.getFilenameLength('\u{1F600}.png')).toBe(5); + } finally { + Intl.Segmenter = segmenter; + } + }); + + test('reports a filename over the maximum length with the plural message', () => { + const constraint = createConstraint({ filenameMaxLength: 5 }); + + expect(constraint.validate([createFile('avatar.png', 10)])).toStrictEqual([ + 'The filename is too long. It should have 5 characters or less.', + ]); + expect(constraint.validate([createFile('a.png', 10)])).toStrictEqual([]); + }); + + test('uses the singular message for a maximum length of one', () => { + const constraint = createConstraint({ filenameMaxLength: 1 }); + + expect(constraint.validate([createFile('avatar.png', 10)])).toStrictEqual([ + 'The filename is too long. It should have 1 character or less.', + ]); + }); +}); + +describe('SymfonyComponentValidatorConstraintsFile values', () => { + test('validates every file of a multiple upload', () => { + const constraint = createConstraint({ maxSize: 100 }); + + expect(constraint.validate([ + createFile('small.png', 10), + createFile('big.png', 250), + ])).toStrictEqual([ + 'The file is too large (250 bytes). Allowed maximum size is 100 bytes.', + ]); + }); + + test('accepts a single file object', () => { + const constraint = createConstraint({ maxSize: 100 }); + + expect(constraint.validate(createFile('big.png', 250))).toStrictEqual([ + 'The file is too large (250 bytes). Allowed maximum size is 100 bytes.', + ]); + }); + + test('validates a real File of the browser', () => { + const constraint = createConstraint({ maxSize: 2, mimeTypes: ['image/png'] }); + + expect(constraint.validate([new File(['abc'], 'avatar.png', { type: 'image/png' })])).toStrictEqual([ + 'The file is too large (3 bytes). Allowed maximum size is 2 bytes.', + ]); + }); + + test.each([ + [[]], + [''], + ['/tmp/upload.png'], + [null], + [undefined], + [{ name: 'avatar.png' }], + ])( + 'leaves a value without file information alone: %p', + (value) => { + const constraint = createConstraint({ maxSize: 1, mimeTypes: ['image/png'] }); + + expect(constraint.validate(value)).toStrictEqual([]); + }, + ); +}); diff --git a/src/Resources/public/js/constraints/globals.test.js b/src/Resources/public/js/constraints/globals.test.js index 72e7abb..d6c61da 100644 --- a/src/Resources/public/js/constraints/globals.test.js +++ b/src/Resources/public/js/constraints/globals.test.js @@ -11,6 +11,7 @@ const constraintGlobals = [ 'SymfonyComponentValidatorConstraintsEmail', 'SymfonyComponentValidatorConstraintsEqualTo', 'SymfonyComponentValidatorConstraintsFalse', + 'SymfonyComponentValidatorConstraintsFile', 'SymfonyComponentValidatorConstraintsGreaterThan', 'SymfonyComponentValidatorConstraintsGreaterThanOrEqual', 'SymfonyComponentValidatorConstraintsIdenticalTo', diff --git a/src/Resources/public/js/constraints/index.js b/src/Resources/public/js/constraints/index.js index 06ec167..4584b98 100644 --- a/src/Resources/public/js/constraints/index.js +++ b/src/Resources/public/js/constraints/index.js @@ -6,6 +6,7 @@ import './Date.js'; import './DateTime.js'; import './Email.js'; import './EqualTo.js'; +import './File.js'; import './GreaterThan.js'; import './GreaterThanOrEqual.js'; import './IdenticalTo.js';