Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Form] turn failed file uploads into form errors #30895

Merged
merged 1 commit into from Apr 6, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/Symfony/Component/Form/Extension/Core/Type/FileType.php
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
Expand All @@ -22,6 +23,15 @@

class FileType extends AbstractType
{
const KIB_BYTES = 1024;
const MIB_BYTES = 1048576;

private static $suffixes = [
1 => 'bytes',
self::KIB_BYTES => 'KiB',
self::MIB_BYTES => 'MiB',
];

/**
* {@inheritdoc}
*/
Expand All @@ -43,6 +53,10 @@ public function buildForm(FormBuilderInterface $builder, array $options)
foreach ($files as $file) {
if ($requestHandler->isFileUpload($file)) {
$data[] = $file;

if (method_exists($requestHandler, 'getUploadFileError') && null !== $errorCode = $requestHandler->getUploadFileError($file)) {
$form->addError($this->getFileUploadError($errorCode));
}
}
}

Expand All @@ -54,6 +68,8 @@ public function buildForm(FormBuilderInterface $builder, array $options)
}

$event->setData($data);
} elseif ($requestHandler->isFileUpload($event->getData()) && method_exists($requestHandler, 'getUploadFileError') && null !== $errorCode = $requestHandler->getUploadFileError($event->getData())) {
$form->addError($this->getFileUploadError($errorCode));
} elseif (!$requestHandler->isFileUpload($event->getData())) {
$event->setData(null);
}
Expand Down Expand Up @@ -116,4 +132,103 @@ public function getBlockPrefix()
{
return 'file';
}

private function getFileUploadError($errorCode)
{
$messageParameters = [];

if (UPLOAD_ERR_INI_SIZE === $errorCode) {
list($limitAsString, $suffix) = $this->factorizeSizes(0, self::getMaxFilesize());
$messageTemplate = 'The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.';
$messageParameters = [
'{{ limit }}' => $limitAsString,
'{{ suffix }}' => $suffix,
];
} elseif (UPLOAD_ERR_FORM_SIZE === $errorCode) {
$messageTemplate = 'The file is too large.';
} else {
$messageTemplate = 'The file could not be uploaded.';
}

return new FormError($messageTemplate, $messageTemplate, $messageParameters);
}

/**
* Returns the maximum size of an uploaded file as configured in php.ini.
*
* This method should be kept in sync with Symfony\Component\HttpFoundation\File\UploadedFile::getMaxFilesize().
*
* @return int The maximum size of an uploaded file in bytes
*/
private static function getMaxFilesize()
{
$iniMax = strtolower(ini_get('upload_max_filesize'));

if ('' === $iniMax) {
return PHP_INT_MAX;
}

$max = ltrim($iniMax, '+');
if (0 === strpos($max, '0x')) {
$max = \intval($max, 16);
} elseif (0 === strpos($max, '0')) {
$max = \intval($max, 8);
} else {
$max = (int) $max;
}

switch (substr($iniMax, -1)) {
case 't': $max *= 1024;
// no break
case 'g': $max *= 1024;
// no break
case 'm': $max *= 1024;
// no break
case 'k': $max *= 1024;
}

return $max;
}

/**
* Converts the limit to the smallest possible number
* (i.e. try "MB", then "kB", then "bytes").
*
* This method should be kept in sync with Symfony\Component\Validator\Constraints\FileValidator::factorizeSizes().
*/
private function factorizeSizes($size, $limit)
{
$coef = self::MIB_BYTES;
$coefFactor = self::KIB_BYTES;

$limitAsString = (string) ($limit / $coef);

// Restrict the limit to 2 decimals (without rounding! we
// need the precise value)
while (self::moreDecimalsThan($limitAsString, 2)) {
$coef /= $coefFactor;
$limitAsString = (string) ($limit / $coef);
}

// Convert size to the same measure, but round to 2 decimals
$sizeAsString = (string) round($size / $coef, 2);

// If the size and limit produce the same string output
// (due to rounding), reduce the coefficient
while ($sizeAsString === $limitAsString) {
$coef /= $coefFactor;
$limitAsString = (string) ($limit / $coef);
$sizeAsString = (string) round($size / $coef, 2);
}

return [$limitAsString, self::$suffixes[$coef]];
}

/**
* This method should be kept in sync with Symfony\Component\Validator\Constraints\FileValidator::moreDecimalsThan().
*/
private static function moreDecimalsThan($double, $numberOfDecimals)
{
return \strlen((string) $double) > \strlen(round($double, $numberOfDecimals));
}
}
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\Form\RequestHandlerInterface;
use Symfony\Component\Form\Util\ServerParams;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;

/**
Expand Down Expand Up @@ -115,4 +116,16 @@ public function isFileUpload($data)
{
return $data instanceof File;
}

/**
* @return int|null
*/
public function getUploadFileError($data)
{
if (!$data instanceof UploadedFile || $data->isValid()) {
return null;
}

return $data->getError();
}
}
24 changes: 24 additions & 0 deletions src/Symfony/Component/Form/NativeRequestHandler.php
Expand Up @@ -135,6 +135,30 @@ public function isFileUpload($data)
return \is_array($data) && isset($data['error']) && \is_int($data['error']);
}

/**
* @return int|null
*/
public function getUploadFileError($data)
{
if (!\is_array($data)) {
return null;
}

if (!isset($data['error'])) {
return null;
}

if (!\is_int($data['error'])) {
return null;
}

if (UPLOAD_ERR_OK === $data['error']) {
return null;
}

return $data['error'];
}

/**
* Returns the method used to submit the request to the server.
*
Expand Down
24 changes: 24 additions & 0 deletions src/Symfony/Component/Form/Tests/AbstractRequestHandlerTest.php
Expand Up @@ -360,6 +360,28 @@ public function testInvalidFilesAreRejected()
$this->assertFalse($this->requestHandler->isFileUpload($this->getInvalidFile()));
}

/**
* @dataProvider uploadFileErrorCodes
*/
public function testFailedFileUploadIsTurnedIntoFormError($errorCode, $expectedErrorCode)
{
$this->assertSame($expectedErrorCode, $this->requestHandler->getUploadFileError($this->getFailedUploadedFile($errorCode)));
}

public function uploadFileErrorCodes()
{
return [
'no error' => [UPLOAD_ERR_OK, null],
'upload_max_filesize ini directive' => [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_INI_SIZE],
'MAX_FILE_SIZE from form' => [UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_FORM_SIZE],
'partially uploaded' => [UPLOAD_ERR_PARTIAL, UPLOAD_ERR_PARTIAL],
'no file upload' => [UPLOAD_ERR_NO_FILE, UPLOAD_ERR_NO_FILE],
'missing temporary directory' => [UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_NO_TMP_DIR],
'write failure' => [UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_CANT_WRITE],
'stopped by extension' => [UPLOAD_ERR_EXTENSION, UPLOAD_ERR_EXTENSION],
];
}

abstract protected function setRequestData($method, $data, $files = []);

abstract protected function getRequestHandler();
Expand All @@ -368,6 +390,8 @@ abstract protected function getUploadedFile($suffix = '');

abstract protected function getInvalidFile();

abstract protected function getFailedUploadedFile($errorCode);

protected function createForm($name, $method = null, $compound = false)
{
$config = $this->createBuilder($name, $compound);
Expand Down
Expand Up @@ -184,6 +184,128 @@ public function requestHandlerProvider()
];
}

/**
* @dataProvider uploadFileErrorCodes
*/
public function testFailedFileUploadIsTurnedIntoFormErrorUsingHttpFoundationRequestHandler($errorCode, $expectedErrorMessage)
{
$form = $this->factory
->createBuilder(static::TESTED_TYPE)
->setRequestHandler(new HttpFoundationRequestHandler())
->getForm();
$form->submit(new UploadedFile(__DIR__.'/../../../Fixtures/foo', 'foo', null, null, $errorCode, true));

if (UPLOAD_ERR_OK === $errorCode) {
$this->assertTrue($form->isValid());
} else {
$this->assertFalse($form->isValid());
$this->assertSame($expectedErrorMessage, $form->getErrors()[0]->getMessage());
}
}

/**
* @dataProvider uploadFileErrorCodes
*/
public function testFailedFileUploadIsTurnedIntoFormErrorUsingNativeRequestHandler($errorCode, $expectedErrorMessage)
{
$form = $this->factory
->createBuilder(static::TESTED_TYPE)
->setRequestHandler(new NativeRequestHandler())
->getForm();
$form->submit([
'name' => 'foo.txt',
'type' => 'text/plain',
'tmp_name' => 'owfdskjasdfsa',
'error' => $errorCode,
'size' => 100,
]);

if (UPLOAD_ERR_OK === $errorCode) {
$this->assertTrue($form->isValid());
} else {
$this->assertFalse($form->isValid());
$this->assertSame($expectedErrorMessage, $form->getErrors()[0]->getMessage());
}
}

/**
* @dataProvider uploadFileErrorCodes
*/
public function testMultipleSubmittedFailedFileUploadsAreTurnedIntoFormErrorUsingHttpFoundationRequestHandler($errorCode, $expectedErrorMessage)
{
$form = $this->factory
->createBuilder(static::TESTED_TYPE, null, [
'multiple' => true,
])
->setRequestHandler(new HttpFoundationRequestHandler())
->getForm();
$form->submit([
new UploadedFile(__DIR__.'/../../../Fixtures/foo', 'foo', null, null, $errorCode, true),
new UploadedFile(__DIR__.'/../../../Fixtures/foo', 'bar', null, null, $errorCode, true),
]);

if (UPLOAD_ERR_OK === $errorCode) {
$this->assertTrue($form->isValid());
} else {
$this->assertFalse($form->isValid());
$this->assertCount(2, $form->getErrors());
$this->assertSame($expectedErrorMessage, $form->getErrors()[0]->getMessage());
$this->assertSame($expectedErrorMessage, $form->getErrors()[1]->getMessage());
}
}

/**
* @dataProvider uploadFileErrorCodes
*/
public function testMultipleSubmittedFailedFileUploadsAreTurnedIntoFormErrorUsingNativeRequestHandler($errorCode, $expectedErrorMessage)
{
$form = $this->factory
->createBuilder(static::TESTED_TYPE, null, [
'multiple' => true,
])
->setRequestHandler(new NativeRequestHandler())
->getForm();
$form->submit([
[
'name' => 'foo.txt',
'type' => 'text/plain',
'tmp_name' => 'owfdskjasdfsa',
'error' => $errorCode,
'size' => 100,
],
[
'name' => 'bar.txt',
'type' => 'text/plain',
'tmp_name' => 'owfdskjasdfsa',
'error' => $errorCode,
'size' => 100,
],
]);

if (UPLOAD_ERR_OK === $errorCode) {
$this->assertTrue($form->isValid());
} else {
$this->assertFalse($form->isValid());
$this->assertCount(2, $form->getErrors());
$this->assertSame($expectedErrorMessage, $form->getErrors()[0]->getMessage());
$this->assertSame($expectedErrorMessage, $form->getErrors()[1]->getMessage());
}
}

public function uploadFileErrorCodes()
{
return [
'no error' => [UPLOAD_ERR_OK, null],
'upload_max_filesize ini directive' => [UPLOAD_ERR_INI_SIZE, 'The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.'],
'MAX_FILE_SIZE from form' => [UPLOAD_ERR_FORM_SIZE, 'The file is too large.'],
'partially uploaded' => [UPLOAD_ERR_PARTIAL, 'The file could not be uploaded.'],
'no file upload' => [UPLOAD_ERR_NO_FILE, 'The file could not be uploaded.'],
'missing temporary directory' => [UPLOAD_ERR_NO_TMP_DIR, 'The file could not be uploaded.'],
'write failure' => [UPLOAD_ERR_CANT_WRITE, 'The file could not be uploaded.'],
'stopped by extension' => [UPLOAD_ERR_EXTENSION, 'The file could not be uploaded.'],
];
}

private function createUploadedFile(RequestHandlerInterface $requestHandler, $path, $originalName)
{
if ($requestHandler instanceof HttpFoundationRequestHandler) {
Expand Down
Expand Up @@ -56,4 +56,9 @@ protected function getInvalidFile()
{
return 'file:///etc/passwd';
}

protected function getFailedUploadedFile($errorCode)
{
return new UploadedFile(__DIR__.'/../../Fixtures/foo', 'foo', null, null, $errorCode, true);
}
}
11 changes: 11 additions & 0 deletions src/Symfony/Component/Form/Tests/NativeRequestHandlerTest.php
Expand Up @@ -275,4 +275,15 @@ protected function getInvalidFile()
'size' => '100',
];
}

protected function getFailedUploadedFile($errorCode)
{
return [
'name' => 'upload.txt',
'type' => 'text/plain',
'tmp_name' => 'owfdskjasdfsa',
'error' => $errorCode,
'size' => 100,
];
}
}