Completed
Push — master ( 7c2dd0...ac3296 )
by Alex
01:28
created

UploadBehavior   F

Complexity

Total Complexity 70

Size/Duplication

Total Lines 541
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 11

Test Coverage

Coverage 87.86%

Importance

Changes 0
Metric Value
wmc 70
lcom 1
cbo 11
dl 0
loc 541
ccs 152
cts 173
cp 0.8786
rs 2.8
c 0
b 0
f 0

22 Methods

Rating   Name   Duplication   Size   Complexity  
A init() 0 14 4
A events() 0 12 1
A beforeValidate() 0 20 5
B beforeSave() 0 24 10
A afterSave() 0 15 4
A afterDelete() 0 7 3
A getUploadPath() 0 9 3
A getUploadedFile() 0 4 1
B resolvePath() 0 22 6
A save() 0 4 1
A delete() 0 13 4
A getFileName() 0 10 4
A sanitize() 0 4 1
A generateFileName() 0 4 1
A afterUpload() 0 4 1
A deleteFile() 0 7 2
A afterValidate() 0 10 3
A uploadFromUrl() 0 21 2
A uploadFromFile() 0 14 2
B setAttributeFile() 0 63 8
A deleteTempFile() 0 7 2
A getUploadUrl() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like UploadBehavior often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use UploadBehavior, and based on these observations, apply Extract Interface, too.

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
     * This method is called at the beginning of inserting or updating a record.
217
     */
218 9
    public function beforeSave()
219
    {
220
        /** @var BaseActiveRecord $model */
221 9
        $model = $this->owner;
222 9
        if (in_array($model->scenario, $this->scenarios)) {
223 9
            if ($this->_file instanceof UploadedFile) {
224 8
                if (!$model->getIsNewRecord() && $model->isAttributeChanged($this->attribute)) {
225 3
                    if ($this->unlinkOnSave === true) {
226 3
                        $this->delete($this->attribute, true);
227
                    }
228
                }
229 8
                $model->setAttribute($this->attribute, $this->_file->name);
230 1
            } elseif (!$this->_import) {
231
                // Protect attribute
232 9
                unset($model->{$this->attribute});
233
            }
234
        } else {
235
            if (!$model->getIsNewRecord() && $model->isAttributeChanged($this->attribute)) {
236
                if ($this->unlinkOnSave === true) {
237
                    $this->delete($this->attribute, true);
238
                }
239
            }
240
        }
241 9
    }
242
243
    /**
244
     * This method is called at the end of inserting or updating a record.
245
     * @throws \yii\base\Exception
246
     */
247 9
    public function afterSave()
248
    {
249 9
        if ($this->_file instanceof UploadedFile) {
250 8
            $path = $this->getUploadPath($this->attribute);
251 8
            if (is_string($path) && FileHelper::createDirectory(dirname($path))) {
252 8
                $this->save($this->_file, $path);
253 8
                $this->deleteTempFile();
254 8
                $this->afterUpload();
255
            } else {
256
                throw new InvalidArgumentException(
257
                    "Directory specified in 'path' attribute doesn't exist or cannot be created."
258
                );
259
            }
260
        }
261 9
    }
262
263
    /**
264
     * This method is invoked after deleting a record.
265
     */
266 1
    public function afterDelete()
267
    {
268 1
        $attribute = $this->attribute;
269 1
        if ($this->unlinkOnDelete && $attribute) {
270 1
            $this->delete($attribute);
271
        }
272 1
    }
273
274
    /**
275
     * Returns file path for the attribute.
276
     * @param string $attribute
277
     * @param boolean $old
278
     * @return string|null the file path.
279
     * @throws \yii\base\InvalidConfigException
280
     */
281 8
    public function getUploadPath($attribute, $old = false)
282
    {
283
        /** @var BaseActiveRecord $model */
284 8
        $model = $this->owner;
285 8
        $path = $this->resolvePath($this->path);
286 8
        $fileName = ($old === true) ? $model->getOldAttribute($attribute) : $model->$attribute;
287
288 8
        return $fileName ? Yii::getAlias($path . '/' . $fileName) : null;
289
    }
290
291
    /**
292
     * Returns file url for the attribute.
293
     * @param string $attribute
294
     * @return string|null
295
     * @throws \yii\base\InvalidConfigException
296
     */
297 1
    public function getUploadUrl($attribute)
298
    {
299
        /** @var BaseActiveRecord $model */
300 1
        $model = $this->owner;
301 1
        $url = $this->resolvePath($this->url);
302 1
        $fileName = $model->getOldAttribute($attribute);
303
304 1
        return $fileName ? Yii::getAlias($url . '/' . $fileName) : null;
305
    }
306
307
    /**
308
     * Returns the UploadedFile instance.
309
     * @return UploadedFile
310
     */
311
    protected function getUploadedFile()
312
    {
313
        return $this->_file;
314
    }
315
316
    /**
317
     * Replaces all placeholders in path variable with corresponding values.
318
     */
319 9
    protected function resolvePath($path)
320
    {
321
        /** @var BaseActiveRecord $model */
322 9
        $model = $this->owner;
323 9
        if (is_string($path)) {
324 9
            return preg_replace_callback('/{([^}]+)}/', function ($matches) use ($model) {
325 9
                $name = $matches[1];
326 9
                $attribute = ArrayHelper::getValue($model, $name);
327 9
                if (is_string($attribute) || is_numeric($attribute)) {
328 9
                    return $attribute;
329
                } else {
330
                    return $matches[0];
331
                }
332 9
            }, $path);
333
        } elseif (is_callable($path) || is_array($path)) {
334
            return call_user_func($path, $model);
335
        } else {
336
            throw new InvalidArgumentException(
337
                '$path argument must be a string, array or callable: ' . gettype($path) . ' given.'
338
            );
339
        }
340
    }
341
342
    /**
343
     * Saves the uploaded file.
344
     * @param UploadedFile $file the uploaded file instance
345
     * @param string $path the file path used to save the uploaded file
346
     * @return boolean true whether the file is saved successfully
347
     */
348 8
    protected function save($file, $path)
349
    {
350 8
        return $file->saveAs($path, $this->deleteTempFile);
351
    }
352
353
    /**
354
     * Deletes old file.
355
     * @param string $attribute
356
     * @param boolean $old
357
     */
358 4
    protected function delete($attribute, $old = false)
359
    {
360 4
        $path = $this->getUploadPath($attribute, $old);
361
362 4
        $this->deleteFile($path);
0 ignored issues
show
Bug introduced by
It seems like $path defined by $this->getUploadPath($attribute, $old) on line 360 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...
363
364 4
        if ($this->deleteEmptyDir) {
365 4
            $dir = dirname($path);
366 4
            if (is_dir($dir) && count(scandir($dir)) == 2) {
367 1
                rmdir($dir);
368
            }
369
        }
370 4
    }
371
372
    /**
373
     * @param UploadedFile $file
374
     * @return string
375
     */
376 10
    protected function getFileName($file)
377
    {
378 10
        if ($this->generateNewName && !$this->_import) {
379 1
            return $this->generateNewName instanceof Closure
380
                ? call_user_func($this->generateNewName, $file)
381 1
                : $this->generateFileName($file);
382
        } else {
383 9
            return $this->sanitize($file->name);
384
        }
385
    }
386
387
    /**
388
     * Replaces characters in strings that are illegal/unsafe for filename.
389
     *
390
     * #my*  unsaf<e>&file:name?".png
391
     *
392
     * @param string $filename the source filename to be "sanitized"
393
     * @return boolean string the sanitized filename
394
     */
395 10
    public static function sanitize($filename)
396
    {
397 10
        return str_replace([' ', '"', '\'', '&', '/', '\\', '?', '#'], '-', $filename);
398
    }
399
400
    /**
401
     * Generates random filename.
402
     * @param UploadedFile $file
403
     * @return string
404
     */
405 1
    protected function generateFileName($file)
406
    {
407 1
        return uniqid() . '.' . $file->extension;
408
    }
409
410
    /**
411
     * This method is invoked after uploading a file.
412
     * The default implementation raises the [[EVENT_AFTER_UPLOAD]] event.
413
     * You may override this method to do postprocessing after the file is uploaded.
414
     * Make sure you call the parent implementation so that the event is raised properly.
415
     */
416 8
    protected function afterUpload()
417
    {
418 8
        $this->owner->trigger(self::EVENT_AFTER_UPLOAD);
419 8
    }
420
421
    /**
422
     * Delete file from path
423
     * @param string $path
424
     */
425 5
    protected function deleteFile($path)
426
    {
427 5
        if (is_file($path)) {
428 4
            unlink($path);
429
        }
430 5
        return;
431
    }
432
433
    /**
434
     * Set old attribute value if has attribute validation error
435
     */
436 10
    public function afterValidate()
437
    {
438
        /** @var BaseActiveRecord $model */
439 10
        $model = $this->owner;
440
441 10
        if ($this->restoreValueAfterFailValidation && $model->hasErrors($this->attribute))
442 2
            $model->setAttribute($this->attribute, $model->getOldAttribute($this->attribute));
443
444 10
        return;
445
    }
446
447
    /**
448
     * Upload file from url
449
     *
450
     * @param $attribute string name of attribute with attached UploadBehavior
451
     * @param $url string
452
     * @throws InvalidConfigException
453
     * @throws \yii\base\Exception
454
     * @throws \yii\httpclient\Exception
455
     */
456 2
    public function uploadFromUrl($attribute, $url) {
457
458 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...
459
460 2
        $client = new Client();
461 2
        $response = $client->createRequest()
462 2
            ->setUrl($url)
463 2
            ->setMethod('GET')
464 2
            ->send();
465 2
        $contentType = $response->getHeaders()->get('Content-Type');
0 ignored issues
show
Unused Code introduced by
$contentType 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...
466
467 2
        if ($response->isOk) {
468
469 1
            $fileContent = $response->content;
470 1
            $this->setAttributeFile($attribute, $url, $fileContent);
471
472
        }
473
        else {
474 1
            throw new InvalidArgumentException('url $url not valid');
475
        }
476 1
    }
477
478
    /**
479
     * Upload file from local storage
480
     *
481
     * @param $attribute string name of attribute with attached UploadBehavior
482
     * @param $filename
483
     * @throws InvalidConfigException
484
     * @throws \yii\base\Exception
485
     */
486 3
    public function uploadFromFile($attribute, $filename) {
487
488 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...
489
490 3
        $file_path = \Yii::getAlias($filename);
491
492 3
        if (file_exists($file_path)) {
493 2
            $this->setAttributeFile($attribute, $file_path);
494
        }
495
        else {
496 1
            throw new InvalidArgumentException('file $filename not exist');
497
        }
498
499 2
    }
500
501
    /**
502
     * @param $attribute
503
     * @param $url
504
     * @param string $fileContent
505
     * @throws InvalidConfigException
506
     * @throws \yii\base\Exception
507
     */
508 3
    protected function setAttributeFile($attribute, $filePath, $fileContent = null)
509
    {
510
        /** @var BaseActiveRecord $model */
511 3
        $model = $this->owner;
512
513 3
        $old_value = $model->getAttribute($attribute);
514
515 3
        $temp_filename = uniqid();
516 3
        $temp_file_path = \Yii::getAlias($this->tempFolder) . $temp_filename;
517
518
        try {
519 3
            if ($fileContent === null) {
520 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...
521
            } else {
522 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...
523
            }
524
525 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...
526
527 3
            $pathinfo = pathinfo($filePath);
528
529
            //check extension by mime type
530 3
            $mime = FileHelper::getMimeType($temp_file_path);
531 3
            $extension = FileHelper::getExtensionsByMimeType($mime);
532
533
            //compare with pathinfo values
534 3
            if (in_array($pathinfo['extension'], $extension)) {
535 3
                $extension = $pathinfo['extension'];
536
            } else {
537
                $extension = $extension[0];
538
            }
539
540
            //get full filename
541 3
            if ($this->generateNewName) {
542
                $full_filename = basename($temp_filename) . '.' . $extension;
543
            } else {
544 3
                $full_filename = $pathinfo['filename'] . '.' . $extension;
545
            }
546
547
            //for validation
548 3
            $upload = new UploadedFile();
549 3
            $upload->tempName = $temp_file_path;
550 3
            $upload->name = basename($full_filename);
551
552 3
            $model->setAttribute($attribute, $upload);
553
            //check validation rules in model
554 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...
555
556 2
                $this->_file = $upload;
557
558 2
                $file_path = $this->getUploadPath($attribute);
559
                //copy file to uploadpath folder
560 2
                if (is_string($file_path) && FileHelper::createDirectory(dirname($file_path))) {
561 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...
562
                }
563
            } else {
564 1
                $model->setAttribute($attribute, $old_value);
565 3
                $this->deleteTempFile();
566
            }
567
        } catch (\Exception $e) {
568
            $this->deleteTempFile();
569
        }
570 3
    }
571
572
    /**
573
     * remove temp file
574
     */
575 9
    protected function deleteTempFile()
576
    {
577 9
        if ($this->_temp_file_path !== null) {
578 3
            $this->deleteFile($this->_temp_file_path);
579 3
            $this->_temp_file_path = null;
580
        }
581 9
    }
582
}
583