Completed
Push — master ( ac3296...e884cb )
by Alex
01:39
created

UploadBehavior::setAttributeByImportFile()   B

Complexity

Conditions 8
Paths 118

Size

Total Lines 63

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 8.125

Importance

Changes 0
Metric Value
dl 0
loc 63
ccs 28
cts 32
cp 0.875
rs 7.4428
c 0
b 0
f 0
cc 8
nc 118
nop 3
crap 8.125

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace mohorev\file;
4
5
use Closure;
6
use Yii;
7
use yii\base\Behavior;
8
use yii\base\InvalidArgumentException;
9
use yii\base\InvalidConfigException;
10
use yii\db\BaseActiveRecord;
11
use yii\helpers\ArrayHelper;
12
use yii\helpers\FileHelper;
13
use yii\httpclient\Client;
14
use yii\web\UploadedFile;
15
16
/**
17
 * UploadBehavior automatically uploads file and fills the specified attribute
18
 * with a value of the name of the uploaded file.
19
 *
20
 * To use UploadBehavior, insert the following code to your ActiveRecord class:
21
 *
22
 * ```php
23
 * use mohorev\file\UploadBehavior;
24
 *
25
 * function behaviors()
26
 * {
27
 *     return [
28
 *         [
29
 *             'class' => UploadBehavior::class,
30
 *             'attribute' => 'file',
31
 *             'scenarios' => ['insert', 'update'],
32
 *             'path' => '@webroot/upload/{id}',
33
 *             'url' => '@web/upload/{id}',
34
 *         ],
35
 *     ];
36
 * }
37
 * ```
38
 *
39
 * @author Alexander Mohorev <[email protected]>
40
 * @author Alexey Samoylov <[email protected]>
41
 */
42
class UploadBehavior extends Behavior
43
{
44
    /**
45
     * @event Event an event that is triggered after a file is uploaded.
46
     */
47
    const EVENT_AFTER_UPLOAD = 'afterUpload';
48
49
    /**
50
     * @var string the attribute which holds the attachment.
51
     */
52
    public $attribute;
53
    /**
54
     * @var array the scenarios in which the behavior will be triggered
55
     */
56
    public $scenarios = [];
57
    /**
58
     * @var string|callable|array Base path or path alias to the directory in which to save files,
59
     * or callable for setting up your custom path generation logic.
60
     * If $path is callable, callback signature should be as follow and return a string:
61
     *
62
     * ```php
63
     * function (\yii\db\ActiveRecord $model)
64
     * {
65
     *     // do something...
66
     *     return $string;
67
     * }
68
     * ```
69
     * If this property is set up as array, it should be, for example, like as follow ['\app\models\UserProfile', 'buildAvatarPath'],
70
     * where first element is class name, while second is its static method that should be called for path generation.
71
     *
72
     * Example:
73
     * ```php
74
     * public static function buildAvatarPath(\yii\db\ActiveRecord $model)
75
     * {
76
     *      $basePath = '@webroot/upload/avatars/';
77
     *      $suffix = implode('/', array_slice(str_split(md5($model->id), 2), 0, 2));
78
     *      return $basePath . $suffix;
79
     * }
80
     * ```
81
     */
82
    public $path;
83
    /**
84
     * @var string|callable|array Base URL or path alias for this file,
85
     * or callable for setting up your custom URL generation logic.
86
     * If $url is callable, callback signature should be as follow and return a string:
87
     *
88
     * ```php
89
     * function (\yii\db\ActiveRecord $model)
90
     * {
91
     *     // do something...
92
     *     return $string;
93
     * }
94
     * ```
95
     * If this property is set up as array, it should be, for example, like as follow ['\app\models\UserProfile', 'buildAvatarUrl'],
96
     * where first element is class name, while second is its static method that should be called for URL generation.
97
     *
98
     * Example:
99
     * ```php
100
     * public static function buildAvatarUrl(\yii\db\ActiveRecord $model)
101
     * {
102
     *      $baseUrl = '@web/upload/avatars/';
103
     *      $suffix = implode('/', array_slice(str_split(md5($model->id), 2), 0, 2));
104
     *      return $baseUrl . $suffix;
105
     * }
106
     * ```
107
     */
108
    public $url;
109
    /**
110
     * @var bool Getting file instance by name
111
     */
112
    public $instanceByName = false;
113
    /**
114
     * @var boolean|callable generate a new unique name for the file
115
     * set true or anonymous function takes the old filename and returns a new name.
116
     * @see self::generateFileName()
117
     */
118
    public $generateNewName = true;
119
    /**
120
     * @var boolean If `true` current attribute file will be deleted
121
     */
122
    public $unlinkOnSave = true;
123
    /**
124
     * @var boolean If `true` current attribute file will be deleted after model deletion.
125
     */
126
    public $unlinkOnDelete = true;
127
    /**
128
     * @var boolean $deleteTempFile whether to delete the temporary file after saving.
129
     */
130
    public $deleteTempFile = true;
131
    /**
132
     * @var boolean $deleteEmptyDir whether to delete the empty directory after model deletion.
133
     */
134
    public $deleteEmptyDir = true;
135
    /**
136
     * @var bool restore old value after fail attribute validation
137
     */
138
    public $restoreValueAfterFailValidation = true;
139
    /**
140
     * @var string temporary folder name
141
     */
142
    public $tempFolder = '@runtime';
143
    /**
144
     * @var UploadedFile the uploaded file instance.
145
     */
146
    private $_file;
147
    /**
148
     * @var import flag for not generate new filename on import
149
     */
150
    private $_import;
151
    /**
152
     * @var temporary filename
153
     */
154
    private $_temp_file_path;
155
156
157
    /**
158
     * @inheritdoc
159
     */
160 17
    public function init()
161
    {
162 17
        parent::init();
163
164 17
        if ($this->attribute === null) {
165
            throw new InvalidConfigException('The "attribute" property must be set.');
166
        }
167 17
        if ($this->path === null) {
168
            throw new InvalidConfigException('The "path" property must be set.');
169
        }
170 17
        if ($this->url === null) {
171
            throw new InvalidConfigException('The "url" property must be set.');
172
        }
173 17
    }
174
175
    /**
176
     * @inheritdoc
177
     */
178 17
    public function events()
179
    {
180
        return [
181 17
            BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
182 17
            BaseActiveRecord::EVENT_AFTER_VALIDATE => 'afterValidate',
183 17
            BaseActiveRecord::EVENT_BEFORE_INSERT => 'beforeSave',
184 17
            BaseActiveRecord::EVENT_BEFORE_UPDATE => 'beforeSave',
185 17
            BaseActiveRecord::EVENT_AFTER_INSERT => 'afterSave',
186 17
            BaseActiveRecord::EVENT_AFTER_UPDATE => 'afterSave',
187 17
            BaseActiveRecord::EVENT_AFTER_DELETE => 'afterDelete',
188
        ];
189
    }
190
191
    /**
192
     * This method is invoked before validation starts.
193
     */
194 10
    public function beforeValidate()
195
    {
196
        /** @var BaseActiveRecord $model */
197 10
        $model = $this->owner;
198 10
        if (in_array($model->scenario, $this->scenarios)) {
199 10
            if (($file = $model->getAttribute($this->attribute)) instanceof UploadedFile) {
200 5
                $this->_file = $file;
201
            } else {
202 6
                if ($this->instanceByName === true) {
203
                    $this->_file = UploadedFile::getInstanceByName($this->attribute);
204
                } else {
205 6
                    $this->_file = UploadedFile::getInstance($model, $this->attribute);
206
                }
207
            }
208 10
            if ($this->_file instanceof UploadedFile) {
209 10
                $this->_file->name = $this->getFileName($this->_file);
210 10
                $model->setAttribute($this->attribute, $this->_file);
211
            }
212
        }
213 10
    }
214
215
    /**
216
     * @param UploadedFile $file
217
     * @return string
218
     */
219 10
    protected function getFileName($file)
220
    {
221 10
        if ($this->generateNewName && !$this->_import) {
222 1
            return $this->generateNewName instanceof Closure
223
                ? call_user_func($this->generateNewName, $file)
224 1
                : $this->generateFileName($file);
225
        } else {
226 9
            return $this->sanitize($file->name);
227
        }
228
    }
229
230
    /**
231
     * Generates random filename.
232
     * @param UploadedFile $file
233
     * @return string
234
     */
235 1
    protected function generateFileName($file)
236
    {
237 1
        return uniqid() . '.' . $file->extension;
238
    }
239
240
    /**
241
     * Replaces characters in strings that are illegal/unsafe for filename.
242
     *
243
     * #my*  unsaf<e>&file:name?".png
244
     *
245
     * @param string $filename the source filename to be "sanitized"
246
     * @return boolean string the sanitized filename
247
     */
248 10
    public static function sanitize($filename)
249
    {
250 10
        return str_replace([' ', '"', '\'', '&', '/', '\\', '?', '#'], '-', $filename);
251
    }
252
253
    /**
254
     * This method is called at the beginning of inserting or updating a record.
255
     */
256 9
    public function beforeSave()
257
    {
258
        /** @var BaseActiveRecord $model */
259 9
        $model = $this->owner;
260 9
        if (in_array($model->scenario, $this->scenarios)) {
261 9
            if ($this->_file instanceof UploadedFile) {
262 8
                if (!$model->getIsNewRecord() && $model->isAttributeChanged($this->attribute)) {
263 3
                    if ($this->unlinkOnSave === true) {
264 3
                        $this->delete($this->attribute, true);
265
                    }
266
                }
267 8
                $model->setAttribute($this->attribute, $this->_file->name);
268 1
            } elseif (!$this->_import) {
269
                // Protect attribute
270 9
                unset($model->{$this->attribute});
271
            }
272
        } else {
273
            if (!$model->getIsNewRecord() && $model->isAttributeChanged($this->attribute)) {
274
                if ($this->unlinkOnSave === true) {
275
                    $this->delete($this->attribute, true);
276
                }
277
            }
278
        }
279 9
    }
280
281
    /**
282
     * Deletes old file.
283
     * @param string $attribute
284
     * @param boolean $old
285
     */
286 4
    protected function delete($attribute, $old = false)
287
    {
288 4
        $path = $this->getUploadPath($attribute, $old);
289
290 4
        $this->deleteFile($path);
0 ignored issues
show
Bug introduced by
It seems like $path defined by $this->getUploadPath($attribute, $old) on line 288 can also be of type boolean or null; however, mohorev\file\UploadBehavior::deleteFile() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
291
292 4
        if ($this->deleteEmptyDir) {
293 4
            $dir = dirname($path);
294 4
            if (is_dir($dir) && count(scandir($dir)) == 2) {
295 1
                rmdir($dir);
296
            }
297
        }
298 4
    }
299
300
    /**
301
     * Returns file path for the attribute.
302
     *
303
     * @param string $attribute
304
     * @param boolean $old
305
     * @return string|null the file path.
306
     * @throws \yii\base\InvalidConfigException
307
     */
308 8
    public function getUploadPath($attribute, $old = false)
309
    {
310
        /** @var BaseActiveRecord $model */
311 8
        $model = $this->owner;
312 8
        $path = $this->resolvePath($this->path);
313 8
        $fileName = ($old === true) ? $model->getOldAttribute($attribute) : $model->$attribute;
314
315 8
        return $fileName ? Yii::getAlias($path . '/' . $fileName) : null;
316
    }
317
318
    /**
319
     * Replaces all placeholders in path variable with corresponding values.
320
     */
321 9
    protected function resolvePath($path)
322
    {
323
        /** @var BaseActiveRecord $model */
324 9
        $model = $this->owner;
325 9
        if (is_string($path)) {
326 9
            return preg_replace_callback('/{([^}]+)}/', function ($matches) use ($model) {
327 9
                $name = $matches[1];
328 9
                $attribute = ArrayHelper::getValue($model, $name);
329 9
                if (is_string($attribute) || is_numeric($attribute)) {
330 9
                    return $attribute;
331
                } else {
332
                    return $matches[0];
333
                }
334 9
            }, $path);
335
        } elseif (is_callable($path) || is_array($path)) {
336
            return call_user_func($path, $model);
337
        } else {
338
            throw new InvalidArgumentException(
339
                '$path argument must be a string, array or callable: ' . gettype($path) . ' given.'
340
            );
341
        }
342
    }
343
344
    /**
345
     * Delete file from path
346
     * @param string $path
347
     */
348 5
    protected function deleteFile($path)
349
    {
350 5
        if (is_file($path)) {
351 4
            unlink($path);
352
        }
353 5
        return;
354
    }
355
356
    /**
357
     * This method is called at the end of inserting or updating a record.
358
     * @throws \yii\base\Exception
359
     */
360 9
    public function afterSave()
361
    {
362 9
        if ($this->_file instanceof UploadedFile) {
363 8
            $path = $this->getUploadPath($this->attribute);
364 8
            if (is_string($path) && FileHelper::createDirectory(dirname($path))) {
365 8
                $this->save($this->_file, $path);
366 8
                $this->deleteTempFile();
367 8
                $this->afterUpload();
368
            } else {
369
                throw new InvalidArgumentException(
370
                    "Directory specified in 'path' attribute doesn't exist or cannot be created."
371
                );
372
            }
373
        }
374 9
    }
375
376
    /**
377
     * Saves the uploaded file.
378
     * @param UploadedFile $file the uploaded file instance
379
     * @param string $path the file path used to save the uploaded file
380
     * @return boolean true whether the file is saved successfully
381
     */
382 8
    protected function save($file, $path)
383
    {
384 8
        return $file->saveAs($path, $this->deleteTempFile);
385
    }
386
387
    /**
388
     * Remove temp file if exist
389
     */
390 9
    protected function deleteTempFile()
391
    {
392 9
        if ($this->_temp_file_path !== null) {
393 3
            $this->deleteFile($this->_temp_file_path);
394 3
            $this->_temp_file_path = null;
395
        }
396 9
    }
397
398
    /**
399
     * This method is invoked after uploading a file.
400
     * The default implementation raises the [[EVENT_AFTER_UPLOAD]] event.
401
     * You may override this method to do postprocessing after the file is uploaded.
402
     * Make sure you call the parent implementation so that the event is raised properly.
403
     */
404 9
    protected function afterUpload()
405
    {
406 9
        $this->owner->trigger(self::EVENT_AFTER_UPLOAD);
407 9
    }
408
409
    /**
410
     * This method is invoked after deleting a record.
411
     */
412 1
    public function afterDelete()
413
    {
414 1
        $attribute = $this->attribute;
415 1
        if ($this->unlinkOnDelete && $attribute) {
416 1
            $this->delete($attribute);
417
        }
418 1
    }
419
420
    /**
421
     * Returns file url for the attribute.
422
     *
423
     * @param string $attribute
424
     * @return string|null
425
     * @throws \yii\base\InvalidConfigException
426
     */
427 1
    public function getUploadUrl($attribute)
428
    {
429
        /** @var BaseActiveRecord $model */
430 1
        $model = $this->owner;
431 1
        $url = $this->resolvePath($this->url);
432 1
        $fileName = $model->getOldAttribute($attribute);
433
434 1
        return $fileName ? Yii::getAlias($url . '/' . $fileName) : null;
435
    }
436
437
    /**
438
     * Set old attribute value if has attribute validation error
439
     */
440 10
    public function afterValidate()
441
    {
442
        /** @var BaseActiveRecord $model */
443 10
        $model = $this->owner;
444
445 10
        if ($this->restoreValueAfterFailValidation && $model->hasErrors($this->attribute)) {
446 2
            $model->setAttribute($this->attribute, $model->getOldAttribute($this->attribute));
447
        }
448
449 10
        return;
450
    }
451
452
    /**
453
     * Set attribute by filename or file content with auto set file extension and validation by mime type
454
     *
455
     * @param string $attribute
456
     * @param string $url
0 ignored issues
show
Bug introduced by
There is no parameter named $url. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
457
     * @param string $fileContent
458
     * @throws InvalidConfigException
459
     * @throws \yii\base\Exception
460
     */
461 3
    protected function setAttributeByImportFile($attribute, $filePath, $fileContent = null)
462
    {
463
        /** @var BaseActiveRecord $model */
464 3
        $model = $this->owner;
465
466 3
        $old_value = $model->getAttribute($attribute);
467
468 3
        $temp_filename = uniqid();
469 3
        $temp_file_path = \Yii::getAlias($this->tempFolder) . '/' . $temp_filename;
470
471
        try {
472 3
            if ($fileContent === null) {
473 2
                @copy($filePath, $temp_file_path);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
474
            } else {
475 1
                @file_put_contents($temp_file_path, $fileContent);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
476
            }
477
478 3
            $this->_temp_file_path = $temp_file_path;
0 ignored issues
show
Documentation Bug introduced by
It seems like $temp_file_path of type string is incompatible with the declared type object<mohorev\file\temporary> of property $_temp_file_path.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
479
480 3
            $pathinfo = pathinfo($filePath);
481
482
            //check extension by mime type
483 3
            $mime = FileHelper::getMimeType($temp_file_path);
484 3
            $extension = FileHelper::getExtensionsByMimeType($mime);
485
486
            //compare with pathinfo values
487 3
            if (in_array($pathinfo['extension'], $extension)) {
488 3
                $extension = $pathinfo['extension'];
489
            } else {
490
                $extension = $extension[0];
491
            }
492
493
            //get full filename
494 3
            if ($this->generateNewName) {
495
                $full_filename = basename($temp_filename) . '.' . $extension;
496
            } else {
497 3
                $full_filename = $pathinfo['filename'] . '.' . $extension;
498
            }
499
500
            //for validation
501 3
            $upload = new UploadedFile();
502 3
            $upload->tempName = $temp_file_path;
503 3
            $upload->name = basename($full_filename);
504
505 3
            $model->setAttribute($attribute, $upload);
506
            //check validation rules in model
507 3
            if ($result = $model->validate($attribute)) {
0 ignored issues
show
Unused Code introduced by
$result is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
508
509 2
                $this->_file = $upload;
510
511 2
                $file_path = $this->getUploadPath($attribute);
512
                //copy file to uploadpath folder
513 2
                if (is_string($file_path) && FileHelper::createDirectory(dirname($file_path))) {
514 2
                    @copy($temp_file_path, $file_path);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
515
                }
516
            } else {
517 1
                $model->setAttribute($attribute, $old_value);
518 3
                $this->deleteTempFile();
519
            }
520
        } catch (\Exception $e) {
521
            $this->deleteTempFile();
522
        }
523 3
    }
524
525
    /**
526
     * Upload file from url
527
     *
528
     * @param $attribute string name of attribute with attached UploadBehavior
529
     * @param $url string
530
     * @throws InvalidConfigException
531
     * @throws \yii\base\Exception
532
     * @throws \yii\httpclient\Exception
533
     */
534 2
    public function uploadFromUrl($attribute, $url)
535
    {
536 2
        $this->_import = true;
0 ignored issues
show
Documentation Bug introduced by
It seems like true of type boolean is incompatible with the declared type object<mohorev\file\import> of property $_import.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
537
538 2
        $client = new Client();
539
540 2
        $response = $client->createRequest()
541 2
            ->setUrl($url)
542 2
            ->setMethod('GET')
543 2
            ->send();
544
545 2
        if ($response->isOk) {
546 1
            $fileContent = $response->content;
547 1
            $this->setAttributeByImportFile($attribute, $url, $fileContent);
548 1
            $this->afterUpload();
549
        } else {
550 1
            throw new InvalidArgumentException('url $url not valid');
551
        }
552 1
    }
553
554
    /**
555
     * Import file from local storage
556
     *
557
     * @param $attribute string name of attribute with attached UploadBehavior
558
     * @param $filename
559
     * @throws InvalidConfigException
560
     * @throws \yii\base\Exception
561
     */
562 3
    public function uploadFromFile($attribute, $filename)
563
    {
564 3
        $this->_import = true;
0 ignored issues
show
Documentation Bug introduced by
It seems like true of type boolean is incompatible with the declared type object<mohorev\file\import> of property $_import.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
565
566 3
        $file_path = \Yii::getAlias($filename);
567
568 3
        if (file_exists($file_path)) {
569 2
            $this->setAttributeByImportFile($attribute, $file_path);
570 2
            $this->afterUpload();
571
        } else {
572 1
            throw new InvalidArgumentException('file $filename not exist');
573
        }
574 2
    }
575
}
576