From a464e6a1030021d4d40d19993be1cad65386362f Mon Sep 17 00:00:00 2001 From: 66Ton99 <66ton99@gmail.com> Date: Sat, 22 Aug 2026 20:01:18 +0300 Subject: [PATCH] feat(js): validate the File constraint in the browser The File constraint had no JavaScript counterpart, so maxSize, mimeTypes, extensions and filenameMaxLength were silently skipped and the user only learned about them after a full upload round trip. Add SymfonyComponentValidatorConstraintsFile, checking everything the File API exposes: the size, the sniffed mime type (wildcards included), the extension and the length of the file name. Sizes are factorized the way FileValidator factorizes them so the messages read the same as the server-side ones. Read the "files" list of a file input instead of its fake path, so the constraint receives File objects and NotBlank still sees an empty list as an empty value. Export the "maxSize" option from the factory: it is a protected property behind a magic getter, so the generic public property export of the model never saw it. Image is not covered: its own options need the image decoded first, which is asynchronous in the browser. Fixes formapro/JsFormValidatorBundle#116 --- README.md | 1 + Tests/Unit/JsFormValidatorFactoryTest.php | 30 ++ src/Factory/JsFormValidatorFactory.php | 8 +- src/Resources/doc/3_22.md | 76 ++++ .../public/js/SvarohJsFormValidator.js | 25 ++ .../public/js/SvarohJsFormValidator.test.js | 24 ++ src/Resources/public/js/constraints/File.js | 377 ++++++++++++++++++ .../public/js/constraints/File.test.js | 230 +++++++++++ .../public/js/constraints/globals.test.js | 1 + src/Resources/public/js/constraints/index.js | 1 + 10 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 src/Resources/doc/3_22.md create mode 100644 src/Resources/public/js/constraints/File.js create mode 100644 src/Resources/public/js/constraints/File.test.js diff --git a/README.md b/README.md index 05f8b8c7..d4d7b07a 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 bf8c4be2..752d8e31 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 a8db1af9..3aa9292f 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 00000000..4570ed96 --- /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 0a69fce8..5dc6edc2 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 0acb5eeb..c36ba02a 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 00000000..e2d52a0c --- /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 00000000..1a86b3ac --- /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 72e7abb4..d6c61dab 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 06ec167c..4584b987 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';