UploadBehavior::afterUpload()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
crap 1
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 boolean flag for not generate new filename on import
149
     */
150
    private $_import;
151
    /**
152
     * @var string filename
153
     */
154
    private $_temp_file_path;
155
156
157
    /**
158
     * @inheritdoc
159
     */
160 20
    public function init()
161
    {
162 20
        parent::init();
163
164 20
        if ($this->attribute === null) {
165
            throw new InvalidConfigException('The "attribute" property must be set.');
166
        }
167 20
        if ($this->path === null) {
168
            throw new InvalidConfigException('The "path" property must be set.');
169
        }
170 20
        if ($this->url === null) {
171
            throw new InvalidConfigException('The "url" property must be set.');
172
        }
173 20
    }
174
175
    /**
176
     * @inheritdoc
177
     */
178 20
    public function events()
179
    {
180
        return [
181 20
            BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
182
            BaseActiveRecord::EVENT_AFTER_VALIDATE => 'afterValidate',
183
            BaseActiveRecord::EVENT_BEFORE_INSERT => 'beforeSave',
184
            BaseActiveRecord::EVENT_BEFORE_UPDATE => 'beforeSave',
185
            BaseActiveRecord::EVENT_AFTER_INSERT => 'afterSave',
186
            BaseActiveRecord::EVENT_AFTER_UPDATE => 'afterSave',
187
            BaseActiveRecord::EVENT_AFTER_DELETE => 'afterDelete',
188
        ];
189
    }
190
191
    /**
192
     * This method is invoked before validation starts.
193
     */
194 12
    public function beforeValidate()
195
    {
196
        /** @var BaseActiveRecord $model */
197 12
        $model = $this->owner;
198 12
        if (in_array($model->scenario, $this->scenarios)) {
199 12
            if (($file = $model->getAttribute($this->attribute)) instanceof UploadedFile) {
200 5
                $this->_file = $file;
201
            } else {
202 8
                if ($this->instanceByName === true) {
203
                    $this->_file = UploadedFile::getInstanceByName($this->attribute);
204
                } else {
205 8
                    $this->_file = UploadedFile::getInstance($model, $this->attribute);
206
                }
207
            }
208 12
            if ($this->_file instanceof UploadedFile) {
209 12
                $this->_file->name = $this->getFileName($this->_file);
210 12
                $model->setAttribute($this->attribute, $this->_file);
211
            }
212
        }
213 12
    }
214
215
    /**
216
     * @param UploadedFile $file
217
     * @return string
218
     */
219 12
    protected function getFileName($file)
220
    {
221 12
        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 11
            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 12
    public static function sanitize($filename)
249
    {
250 12
        return str_replace([' ', '"', '\'', '&', '/', '\\', '?', '#'], '-', $filename);
251
    }
252
253
    /**
254
     * This method is called at the beginning of inserting or updating a record.
255
     */
256 11
    public function beforeSave()
257
    {
258
        /** @var BaseActiveRecord $model */
259 11
        $model = $this->owner;
260 11
        if (in_array($model->scenario, $this->scenarios)) {
261 11
            if ($this->_file instanceof UploadedFile) {
262 10
                if (!$model->getIsNewRecord() && $model->isAttributeChanged($this->attribute)) {
263 5
                    if ($this->unlinkOnSave === true) {
264 5
                        $this->delete($this->attribute, true);
265
                    }
266
                }
267 10
                $model->setAttribute($this->attribute, $this->_file->name);
268 3
            } elseif (!$this->_import) {
269
                // Protect attribute
270 11
                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 11
    }
280
281
    /**
282
     * Deletes old file.
283
     * @param string $attribute
284
     * @param boolean $old
285
     */
286 6
    protected function delete($attribute, $old = false)
287
    {
288 6
        $path = $this->getUploadPath($attribute, $old);
289
290 6
        $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 6
        if ($this->deleteEmptyDir) {
293 6
            $dir = dirname($path);
294 6
            if (is_dir($dir) && count(scandir($dir)) == 2) {
295 1
                rmdir($dir);
296
            }
297
        }
298 6
    }
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 10
    public function getUploadPath($attribute, $old = false)
309
    {
310
        /** @var BaseActiveRecord $model */
311 10
        $model = $this->owner;
312 10
        $path = $this->resolvePath($this->path);
313 10
        $fileName = ($old === true) ? $model->getOldAttribute($attribute) : $model->$attribute;
314
315 10
        return $fileName ? Yii::getAlias($path . '/' . $fileName) : null;
316
    }
317
318
    /**
319
     * Replaces all placeholders in path variable with corresponding values.
320
     */
321 11
    protected function resolvePath($path)
322
    {
323
        /** @var BaseActiveRecord $model */
324 11
        $model = $this->owner;
325 11
        if (is_string($path)) {
326 11
            return preg_replace_callback('/{([^}]+)}/', function ($matches) use ($model) {
327 11
                $name = $matches[1];
328 11
                $attribute = ArrayHelper::getValue($model, $name);
329 11
                if (is_string($attribute) || is_numeric($attribute)) {
330 11
                    return $attribute;
331
                } else {
332
                    return $matches[0];
333
                }
334 11
            }, $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 7
    protected function deleteFile($path)
349
    {
350 7
        if (is_file($path)) {
351 4
            unlink($path);
352
        }
353 7
        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 11
    public function afterSave()
361
    {
362 11
        if ($this->_file instanceof UploadedFile) {
363 10
            $path = $this->getUploadPath($this->attribute);
364 10
            if (is_string($path) && FileHelper::createDirectory(dirname($path))) {
365 10
                $this->save($this->_file, $path);
366 10
                $this->deleteTempFile();
367 10
                $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 11
    }
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 10
    protected function save($file, $path)
383
    {
384 10
        return $file->saveAs($path, $this->deleteTempFile);
385
    }
386
387
    /**
388
     * Remove temp file if exist
389
     */
390 11
    protected function deleteTempFile()
391
    {
392 11
        if ($this->_temp_file_path !== null) {
393 3
            $this->deleteFile($this->_temp_file_path);
394 3
            $this->_temp_file_path = null;
395
        }
396 11
    }
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 11
    protected function afterUpload()
405
    {
406 11
        $this->owner->trigger(self::EVENT_AFTER_UPLOAD);
407 11
    }
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 12
    public function afterValidate()
441
    {
442
        /** @var BaseActiveRecord $model */
443 12
        $model = $this->owner;
444
445 12
        if ($this->restoreValueAfterFailValidation && $model->hasErrors($this->attribute)) {
446 2
            $model->setAttribute($this->attribute, $model->getOldAttribute($this->attribute));
447
        }
448
449 12
        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 $filePath
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);
474
            } else {
475 1
                file_put_contents($temp_file_path, $fileContent);
476
            }
477
478 3
            $this->_temp_file_path = $temp_file_path;
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 ($model->validate($attribute)) {
508 2
                $this->_file = $upload;
509
510 2
                $file_path = $this->getUploadPath($attribute);
511
                //copy file to uploadpath folder
512 2
                if (is_string($file_path) && FileHelper::createDirectory(dirname($file_path))) {
513 2
                    copy($temp_file_path, $file_path);
514
                }
515
            } else {
516 1
                $model->setAttribute($attribute, $old_value);
517 3
                $this->deleteTempFile();
518
            }
519
        } catch (\Exception $e) {
520
            $this->deleteTempFile();
521
        }
522 3
    }
523
524
    /**
525
     * Upload file from url
526
     *
527
     * @param $attribute string name of attribute with attached UploadBehavior
528
     * @param $url string
529
     * @throws InvalidConfigException
530
     * @throws \yii\base\Exception
531
     * @throws \yii\httpclient\Exception
532
     */
533 2
    public function uploadFromUrl($attribute, $url)
534
    {
535 2
        $this->_import = true;
536
537 2
        $client = new Client();
538
539 2
        $response = $client->createRequest()
540 2
            ->setUrl($url)
541 2
            ->setMethod('GET')
542 2
            ->send();
543
544 2
        if ($response->isOk) {
545 1
            $fileContent = $response->content;
546 1
            $this->setAttributeByImportFile($attribute, $url, $fileContent);
547 1
            $this->afterUpload();
548
        } else {
549 1
            throw new InvalidArgumentException('url $url not valid');
550
        }
551 1
    }
552
553
    /**
554
     * Import file from local storage
555
     *
556
     * @param $attribute string name of attribute with attached UploadBehavior
557
     * @param $filename
558
     * @throws InvalidConfigException
559
     * @throws \yii\base\Exception
560
     */
561 3
    public function uploadFromFile($attribute, $filename)
562
    {
563 3
        $this->_import = true;
564
565 3
        $file_path = \Yii::getAlias($filename);
566
567 3
        if (file_exists($file_path)) {
568 2
            $this->setAttributeByImportFile($attribute, $file_path);
0 ignored issues
show
Bug introduced by
It seems like $file_path defined by \Yii::getAlias($filename) on line 565 can also be of type boolean; however, mohorev\file\UploadBehav...AttributeByImportFile() 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...
569 2
            $this->afterUpload();
570
        } else {
571 1
            throw new InvalidArgumentException('file $filename not exist');
572
        }
573 2
    }
574
}
575