Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions Tests/Unit/JsFormValidatorFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion src/Factory/JsFormValidatorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
76 changes: 76 additions & 0 deletions src/Resources/doc/3_22.md
Original file line number Diff line number Diff line change
@@ -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
<?php

// src/Form/ProfileType.php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

class ProfileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->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.
25 changes: 25 additions & 0 deletions src/Resources/public/js/SvarohJsFormValidator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*/
Expand Down
24 changes: 24 additions & 0 deletions src/Resources/public/js/SvarohJsFormValidator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<form id="profile"><div><input name="profile[email]" value="a@b.test"></div></form>';
const named = window.SvarohJsFormValidator.findDomElement({
Expand Down
Loading
Loading