Yii2: (Bug) FileInput Forces uploading file with every update

Created on 6 Apr 2016  路  12Comments  路  Source: yiisoft/yii2

I'm implement FileInput in a form, when I create a new record or change the attribute in update it works correctly. However when I update the record without changing the file attribute I get a javascript error 'File cannot be empty' despite the file is already saved and I don't want to update it

Code of FileInput

echo $form->field($model, 'image')->fileInput();

Hidden Input with attribute value is expected

echo $form->field($model, 'image')->hiddenInput(['value'=> $model->image]);

file_empty
inspector
Thanks

Additional info

| Q | A |
| --- | --- |
| Yii version | 2.0.8-dev |
| PHP version | 5.5.9-1ubuntu4.14 |
| Operating system | Linux Elementary OS Freya |

to be verified bug

All 12 comments

Yes, that's definitely not good behavior.

The way I solved it is by creating several new classes and modifying existing behavior in the yii\helpers\Html class.

common/helpers/Html.php

namespace common\helpers;

/**
 * @inheritdoc
 */
class Html extends \yii\helpers\Html
{
     /**
      * @inheritdoc
      */
    public static function activeFileInput($model, $attribute, $options = [])
    {
        // add a hidden field to know when a file has already been uploaded
        $hiddenOptions = ['id' => null];
        if (isset($options['name'])) {
            $hiddenOptions['name'] = $options['name'];
        }
        return static::activeHiddenInput($model, $attribute, $hiddenOptions)
            . static::activeInput('file', $model, $attribute, $options);
    }
}

common/validators/FileHandlerValidator.php

namespace common\validators;

use SPLFileInfo;
use Yii;
use yii\helpers\FileHelper;
use yii\web\UploadedFile;

class FileHandlerValidator extends \yii\validators\FileValidator
{
    public $folderPath = '@webroot';

    public $notFoundFileName;

    public $invalidFileName;

    public $notSavedFile;

    public $fileName;

    public function init()
    {
        parent::init();

        if ($this->notFoundFileName === null) {
            $this->notFoundFileName = 'The file "{fileName}" was not found on folder "{folderPath}".';
        }

        if ($this->invalidFileName === null) {
            $this->invalidFileName = 'The file name "{fileName}" is not valid.';
        }

        if ($this->notSavedFile === null) {
            $this->notSavedFile = 'The file could not be saved to "{folderPath}" error: "{error}".';
        }
    }

    public function validateAttribute($model, $attribute)
    {
        parent::validateAttribute($model, $attribute);

        if (!$model->hasErrors($attribute)
            && $model->$attribute instanceof UploadedFile
        ) {
            $file = $model->$attribute;
            $model->$attribute = $this->fileName($file);
            if (!$file->saveAs(Yii::getAlias(
                "{$this->folderPath}/{$model->$attribute}"
            ))) {
                $this->addError($model, $attribute, $this->notSavedFile, [
                    'folderPath' => $this->folderPath,
                    'error' => $file->error,
                ]);
            }
        }
    }
    public function validateValue($file)
    {
        if (is_string($file)) {
            if (in_array(substr($file, 0, 1), ['/', '.'])) {
                return [$this->invalidFileName, ['fileName' => $file]];
            }

            $fileInfo = new SPLFileInfo(
                Yii::getAlias($this->folderPath . $file)
            );

            if ($fileInfo->getRealPath() === false) {
                return [$this->notFoundFileName, [
                    'folderPath' => $this->folderPath,
                    'fileName' => $file,
                ]];
            }

            if (!empty($this->extensions)
                && !$this->validateFileInfoExtension($fileInfo)
            ) {
                return [$this->wrongExtension, [
                    'file' => $file,
                    'extensions' => implode(', ', $this->extensions)
                ]];
            }

            return null;
        } elseif ($file instanceof UploadedFile) {
            // execute previos funcionality
            return parent::validateValue($file);
        }
        return [$this->invalidFileName, ['fileName' => '']];
    }

    public function fileName(UploadedFile $file)
    {
        if (!empty($this->fileName)) {
            return call_user_func($this->fileName, $file);
        }

        return $file->name;
    }

    public function validateFileInfoExtension(SPLFileInfo $fileInfo)
    {
        if ($this->checkExtensionByMimeType) {
            $mimeType = FileHelper::getMimeType(
                $fileInfo->getRealPath(),
                null,
                false
            );

            if ($mimeType === null) {
                return false;
            }

            if (!in_array(
                $fileInfo->getExtension(),
                FileHelper::getExtensionsByMimeType($mimeType),
                true
            )) {
                return false;
            }
        }

        if (!in_array($fileInfo->getExtension(), $this->extensions, true)) {
            return false;
        }

        return true;
    }
}

commonmodelsGalleryImage.php

namespace common\models;

/**
 * @property integer $id
 * @property string $title
 * @property string $description
 * @property string|UploadedFile $file
 */
class GalleryImage extends \yii\db\ActiveRecord
{
    public function rules()
    {
        return [
            [['file'], 'required'],
            [['title', 'description'], 'string'],
            [
                ['file'],
                'common\\validators\\FileHandlerValidator',
                'folderPath' => '@webroot/galeries'
            ],
        ];
    }
}

If $model->file receives an string that string will be checked know if there is a file with that name in folderPath then security validations will be executed on that file. That allows me to let the user be able to choose from a list of existing images on the galleries or upload a new one when creating or updating a GalleryImage record.

The hidden field generated by fileInput() is also breaking CodeCeption acceptance tests when using the form field name as the selector.

Situation:
Tests:

            $I->selectOption('UserCertification[certification_option_id]', $this->certification['name']);
            $I->seeElement('#usercertification-expiration[required]');
            $I->fillField('UserCertification[expiration]', $expiration);
            $I->attachFile('[name="UserCertification[file]"]', $filename);
            $I->click('Add Certification', 'form div.form-group button.btn.btn-success');

Error:
[LogicException] A FileFormField can only be created from an input tag with a type of file (given type is hidden).

Screen Cap:
screen shot 2016-04-18 at 19 32 23

@davidjeddy

$I->attachFile('[name="UserCertification[file]"][type="file"]', $filename);

we'd need some frontend tests for the framework to verify and fix this correctly...

After #12840

The hidden input should render with given attribute value instead of empty string. It would help in all scenarios where the form has file input field(s) + other fields and the user input does not validate. You still want to accept inputs for the file input field, but don't want to push the user to re-select the file from his PC every time the form does not validate. This little change in helpers/BaseHtml.php would solve this use case:

public static function activeFileInput($model, $attribute, $options = [])
    {
        // add a hidden field so that if a model only has a file field, we can
        // still use isset($_POST[$modelClass]) to detect if the input is submitted
        //$hiddenOptions = ['id' => null, 'value' => ''];
        $hiddenOptions = ['id' => null, 'value' => $model->$attribute];
        if (isset($options['name'])) {
            $hiddenOptions['name'] = $options['name'];
        }
        return static::activeHiddenInput($model, $attribute, $hiddenOptions)
            . static::activeInput('file', $model, $attribute, $options);
    }

@TomasMolnar Would you mind adding some test alongside your proposed change? It would go a long way towards getting your fix implemented for a future release.

I am sorry for my amateurism, I never wrote a piece of test...

@TomasMolnar Not time like the present to start :). Message me if you would like some assistance.

I think the desired behavior for solving this problem can be obtained by using scenarios, and setting FileValidator::skipOnEmpty based on your needs.

Model:

...
const SCENARIO_UPDATE = 'update';

public function rules()
{
    return [
        ...
        [['image'], 'file', 'skipOnEmpty' => ($this->scenario === self::SCENARIO_UPDATE), 'on' => [self::SCENARIO_DEFAULT, self::SCENARIO_UPDATE]],
    ];
}
...
Was this page helpful?
0 / 5 - 0 ratings

Related issues

skcn022 picture skcn022  路  3Comments

kminooie picture kminooie  路  3Comments

MUTOgen picture MUTOgen  路  3Comments

AstRonin picture AstRonin  路  3Comments

indicalabs picture indicalabs  路  3Comments