Test Setup Failed
Push — master ( 2f148a...436d74 )
by Chauncey
01:04 queued 11s
created

FileProperty::pathFor()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 12
rs 9.8666
c 0
b 0
f 0
cc 2
nc 2
nop 1
1
<?php
2
3
namespace Charcoal\Property;
4
5
use finfo;
6
use PDO;
7
use Exception;
8
use InvalidArgumentException;
9
use UnexpectedValueException;
10
11
// From Pimple
12
use Pimple\Container;
13
14
// From 'charcoal-translator'
15
use Charcoal\Translator\Translation;
16
17
// From 'charcoal-property'
18
use Charcoal\Property\AbstractProperty;
19
20
/**
21
 * File Property
22
 */
23
class FileProperty extends AbstractProperty
24
{
25
    const DEFAULT_PUBLIC_ACCESS = false;
26
    const DEFAULT_UPLOAD_PATH = 'uploads/';
27
    const DEFAULT_FILESYSTEM = 'public';
28
    const DEFAULT_OVERWRITE = false;
29
    const ERROR_MESSAGES = [
30
        UPLOAD_ERR_OK         => 'There is no error, the file uploaded with success',
31
        UPLOAD_ERR_INI_SIZE   => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
32
        UPLOAD_ERR_FORM_SIZE  => 'The uploaded file exceeds the MAX_FILE_SIZE directive'.
33
                                 'that was specified in the HTML form',
34
        UPLOAD_ERR_PARTIAL    => 'The uploaded file was only partially uploaded',
35
        UPLOAD_ERR_NO_FILE    => 'No file was uploaded',
36
        UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
37
        UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
38
        UPLOAD_ERR_EXTENSION  => 'A PHP extension stopped the file upload.',
39
    ];
40
41
    /**
42
     * Whether uploaded files should be accessible from the web root.
43
     *
44
     * @var boolean
45
     */
46
    private $publicAccess = self::DEFAULT_PUBLIC_ACCESS;
47
48
    /**
49
     * The relative path to the storage directory.
50
     *
51
     * @var string
52
     */
53
    private $uploadPath = self::DEFAULT_UPLOAD_PATH;
54
55
    /**
56
     * The base path for the Charcoal installation.
57
     *
58
     * @var string
59
     */
60
    private $basePath;
61
62
    /**
63
     * The path to the public / web directory.
64
     *
65
     * @var string
66
     */
67
    private $publicPath;
68
69
    /**
70
     * Whether existing destinations should be overwritten.
71
     *
72
     * @var boolean
73
     */
74
    private $overwrite = self::DEFAULT_OVERWRITE;
75
76
    /**
77
     * Collection of accepted MIME types.
78
     *
79
     * @var string[]
80
     */
81
    private $acceptedMimetypes;
82
83
    /**
84
     * Current file mimetype
85
     *
86
     * @var string
87
     */
88
    private $mimetype;
89
90
    /**
91
     * Maximum allowed file size, in bytes.
92
     *
93
     * @var integer
94
     */
95
    private $maxFilesize;
96
97
    /**
98
     * Current file size, in bytes.
99
     *
100
     * @var integer
101
     */
102
    private $filesize;
103
104
    /**
105
     * @var string
106
     */
107
    private $fallbackFilename;
108
109
    /**
110
     * The filesystem to use while uploading a file.
111
     *
112
     * @var string
113
     */
114
    private $filesystem = self::DEFAULT_FILESYSTEM;
115
116
    /**
117
     * Holds a list of all normalized paths.
118
     *
119
     * @var string[]
120
     */
121
    protected static $normalizePathCache = [];
122
123
    /**
124
     * @return string
125
     */
126
    public function type()
127
    {
128
        return 'file';
129
    }
130
131
    /**
132
     * Set whether uploaded files should be publicly available.
133
     *
134
     * @param  boolean $public Whether uploaded files should be accessible (TRUE) or not (FALSE) from the web root.
135
     * @return self
136
     */
137
    public function setPublicAccess($public)
138
    {
139
        $this->publicAccess = !!$public;
140
141
        return $this;
142
    }
143
144
    /**
145
     * Determine if uploaded files should be publicly available.
146
     *
147
     * @return boolean
148
     */
149
    public function getPublicAccess()
150
    {
151
        return $this->publicAccess;
152
    }
153
154
    /**
155
     * Set the destination (directory) where uploaded files are stored.
156
     *
157
     * The path must be relative to the {@see self::basePath()},
158
     *
159
     * @param  string $path The destination directory, relative to project's root.
160
     * @throws InvalidArgumentException If the path is not a string.
161
     * @return self
162
     */
163
    public function setUploadPath($path)
164
    {
165
        if (!is_string($path)) {
166
            throw new InvalidArgumentException(
167
                'Upload path must be a string'
168
            );
169
        }
170
171
        // Sanitize upload path (force trailing slash)
172
        $this->uploadPath = rtrim($path, '/').'/';
173
174
        return $this;
175
    }
176
177
    /**
178
     * Retrieve the destination for the uploaded file(s).
179
     *
180
     * @return string
181
     */
182
    public function getUploadPath()
183
    {
184
        return $this->uploadPath;
185
    }
186
187
    /**
188
     * Set whether existing destinations should be overwritten.
189
     *
190
     * @param  boolean $overwrite Whether existing destinations should be overwritten (TRUE) or not (FALSE).
191
     * @return self
192
     */
193
    public function setOverwrite($overwrite)
194
    {
195
        $this->overwrite = !!$overwrite;
196
197
        return $this;
198
    }
199
200
    /**
201
     * Determine if existing destinations should be overwritten.
202
     *
203
     * @return boolean
204
     */
205
    public function getOverwrite()
206
    {
207
        return $this->overwrite;
208
    }
209
210
    /**
211
     * Sets the acceptable MIME types for uploaded files.
212
     *
213
     * @param  mixed $types One or many MIME types.
214
     * @throws InvalidArgumentException If the $types argument is not NULL or a list.
215
     * @return self
216
     */
217
    public function setAcceptedMimetypes($types)
218
    {
219
        if (is_array($types)) {
220
            $types = array_filter($types);
221
222
            if (empty($types)) {
223
                $types = null;
224
            }
225
        }
226
227
        if ($types !== null && !is_array($types)) {
228
            throw new InvalidArgumentException(
229
                'Must be an array of acceptable MIME types or NULL'
230
            );
231
        }
232
233
        $this->acceptedMimetypes = $types;
0 ignored issues
show
Documentation Bug introduced by
It seems like $types can be null. However, the property $acceptedMimetypes is declared as array. Maybe change the type of the property to array|null or add a type check?

Our type inference engine has found an assignment of a scalar value (like a string, an integer or null) to a property which is an array.

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

To type hint that a parameter can be either an array or null, you can set a type hint of array and a default value of null. The PHP interpreter will then accept both an array or null for that parameter.

function aContainsB(array $needle = null, array  $haystack) {
    if (!$needle) {
        return false;
    }

    return array_intersect($haystack, $needle) == $haystack;
}

The function can be called with either null or an array for the parameter $needle but will only accept an array as $haystack.

Loading history...
234
        return $this;
235
    }
236
237
    /**
238
     * Determines if any acceptable MIME types are defined.
239
     *
240
     * @return boolean
241
     */
242
    public function hasAcceptedMimetypes()
243
    {
244
        if (!empty($this->acceptedMimetypes)) {
245
            return true;
246
        }
247
248
        return !empty($this->getDefaultAcceptedMimetypes());
249
    }
250
251
    /**
252
     * Retrieves a list of acceptable MIME types for uploaded files.
253
     *
254
     * @return string[]
255
     */
256
    public function getAcceptedMimetypes()
257
    {
258
        if ($this->acceptedMimetypes === null) {
259
            return $this->getDefaultAcceptedMimetypes();
260
        }
261
262
        return $this->acceptedMimetypes;
263
    }
264
265
    /**
266
     * Retrieves the default list of acceptable MIME types for uploaded files.
267
     *
268
     * This method should be overriden.
269
     *
270
     * @return string[]
271
     */
272
    public function getDefaultAcceptedMimetypes()
273
    {
274
        return [];
275
    }
276
277
    /**
278
     * Set the MIME type.
279
     *
280
     * @param  mixed $type The file MIME type.
281
     * @throws InvalidArgumentException If the MIME type argument is not a string.
282
     * @return FileProperty Chainable
283
     */
284
    public function setMimetype($type)
285
    {
286
        if ($type === null || $type === false) {
287
            $this->mimetype = null;
288
            return $this;
289
        }
290
291
        if (!is_string($type)) {
292
            throw new InvalidArgumentException(
293
                'MIME type must be a string'
294
            );
295
        }
296
297
        $this->mimetype = $type;
298
        return $this;
299
    }
300
301
    /**
302
     * Retrieve the MIME type of the property value.
303
     *
304
     * @todo Refactor to support multilingual/multiple files.
305
     *
306
     * @return integer Returns the MIME type for the first value.
307
     */
308
    public function getMimetype()
309
    {
310
        if ($this->mimetype === null) {
311
            $files = $this->parseValAsFileList($this->val());
0 ignored issues
show
Deprecated Code introduced by
The method Charcoal\Property\AbstractProperty::val() has been deprecated.

This method has been deprecated.

Loading history...
312
            if (empty($files)) {
313
                return null;
314
            }
315
316
            $file = reset($files);
317
            $type = $this->getMimetypeFor($file);
0 ignored issues
show
Security Bug introduced by
It seems like $file defined by reset($files) on line 316 can also be of type false; however, Charcoal\Property\FileProperty::getMimetypeFor() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
318
            if ($type === null) {
319
                return null;
320
            }
321
322
            $this->setMimetype($type);
323
        }
324
325
        return $this->mimetype;
326
    }
327
328
    /**
329
     * Extract the MIME type from the given file.
330
     *
331
     * @param  string $file The file to check.
332
     * @return integer|null Returns the file's MIME type,
333
     *     or NULL in case of an error or the file is missing.
334
     */
335
    public function getMimetypeFor($file)
336
    {
337
        if (!$this->isAbsolutePath($file)) {
338
            $file = $this->pathFor($file);
339
        }
340
341
        if (!$this->fileExists($file)) {
342
            return null;
343
        }
344
345
        $info = new finfo(FILEINFO_MIME_TYPE);
346
        $type = $info->file($file);
347
        if (empty($type) || $type === 'inode/x-empty') {
348
            return null;
349
        }
350
351
        return $type;
352
    }
353
354
    /**
355
     * Set the maximium size accepted for an uploaded files.
356
     *
357
     * @param  string|integer $size The maximum file size allowed, in bytes.
358
     * @throws InvalidArgumentException If the size argument is not an integer.
359
     * @return FileProperty Chainable
360
     */
361
    public function setMaxFilesize($size)
362
    {
363
        $this->maxFilesize = $this->parseIniSize($size);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->parseIniSize($size) can also be of type double or string. However, the property $maxFilesize is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
364
365
        return $this;
366
    }
367
368
    /**
369
     * Retrieve the maximum size accepted for uploaded files.
370
     *
371
     * If null or 0, then no limit. Defaults to 128 MB.
372
     *
373
     * @return integer
374
     */
375
    public function getMaxFilesize()
376
    {
377
        if (!isset($this->maxFilesize)) {
378
            return $this->maxFilesizeAllowedByPhp();
379
        }
380
381
        return $this->maxFilesize;
382
    }
383
384
    /**
385
     * Retrieve the maximum size (in bytes) allowed for an uploaded file
386
     * as configured in {@link http://php.net/manual/en/ini.php `php.ini`}.
387
     *
388
     * @param string|null $iniDirective If $iniDirective is provided, then it is filled with
389
     *     the name of the PHP INI directive corresponding to the maximum size allowed.
390
     * @return integer
391
     */
392
    public function maxFilesizeAllowedByPhp(&$iniDirective = null)
393
    {
394
        $postMaxSize = $this->parseIniSize(ini_get('post_max_size'));
395
        $uploadMaxFilesize = $this->parseIniSize(ini_get('upload_max_filesize'));
396
397
        if ($postMaxSize < $uploadMaxFilesize) {
398
            $iniDirective = 'post_max_size';
399
400
            return $postMaxSize;
401
        } else {
402
            $iniDirective = 'upload_max_filesize';
403
404
            return $uploadMaxFilesize;
405
        }
406
    }
407
408
    /**
409
     * @param  integer $size The file size, in bytes.
410
     * @throws InvalidArgumentException If the size argument is not an integer.
411
     * @return FileProperty Chainable
412
     */
413
    public function setFilesize($size)
414
    {
415
        if (!is_int($size) && $size !== null) {
416
            throw new InvalidArgumentException(
417
                'File size must be an integer in bytes'
418
            );
419
        }
420
421
        $this->filesize = $size;
422
        return $this;
423
    }
424
425
    /**
426
     * Retrieve the size of the property value.
427
     *
428
     * @todo Refactor to support multilingual/multiple files.
429
     *
430
     * @return integer Returns the size in bytes for the first value.
431
     */
432
    public function getFilesize()
433
    {
434
        if ($this->filesize === null) {
435
            $files = $this->parseValAsFileList($this->val());
0 ignored issues
show
Deprecated Code introduced by
The method Charcoal\Property\AbstractProperty::val() has been deprecated.

This method has been deprecated.

Loading history...
436
            if (empty($files)) {
437
                return 0;
438
            }
439
440
            $file = reset($files);
441
            $size = $this->getFilesizeFor($file);
0 ignored issues
show
Security Bug introduced by
It seems like $file defined by reset($files) on line 440 can also be of type false; however, Charcoal\Property\FileProperty::getFilesizeFor() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
442
            if ($size === null) {
443
                return 0;
444
            }
445
446
            $this->setFilesize($size);
447
        }
448
449
        return $this->filesize;
450
    }
451
452
    /**
453
     * Extract the size of the given file.
454
     *
455
     * @param  string $file The file to check.
456
     * @return integer|null Returns the file size in bytes,
457
     *     or NULL in case of an error or the file is missing.
458
     */
459
    public function getFilesizeFor($file)
460
    {
461
        if (!$this->isAbsolutePath($file)) {
462
            $file = $this->pathFor($file);
463
        }
464
465
        if (!$this->fileExists($file)) {
466
            return null;
467
        }
468
469
        $size = filesize($file);
470
        if ($size === false) {
471
            return null;
472
        }
473
474
        return $size;
475
    }
476
477
    /**
478
     * Convert number of bytes to largest human-readable unit.
479
     *
480
     * @param  integer $bytes    Number of bytes.
481
     * @param  integer $decimals Precision of number of decimal places. Default 0.
482
     * @return string|null Returns the formatted number or NULL.
483
     */
484
    public function formatFilesize($bytes, $decimals = 2)
485
    {
486
        if ($bytes === 0) {
487
            $factor = 0;
0 ignored issues
show
Unused Code introduced by
$factor 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...
488
        } else {
489
            $factor = floor((strlen($bytes) - 1) / 3);
0 ignored issues
show
Unused Code introduced by
$factor 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...
490
        }
491
492
        $unit = [ 'B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ];
493
494
        $factor = floor((strlen($bytes) - 1) / 3);
495
496
        if (!isset($unit[$factor])) {
497
            $factor = 0;
498
        }
499
500
        return sprintf('%.'.$decimals.'f', ($bytes / pow(1024, $factor))).' '.$unit[$factor];
501
    }
502
503
    /**
504
     * @return array
505
     */
506
    public function validationMethods()
507
    {
508
        $parentMethods = parent::validationMethods();
509
510
        return array_merge($parentMethods, [
511
            'mimetypes',
512
            'filesizes',
513
        ]);
514
    }
515
516
    /**
517
     * Validates the MIME types for the property's value(s).
518
     *
519
     * @return boolean Returns TRUE if all values are valid.
520
     *     Otherwise, returns FALSE and reports issues.
521
     */
522 View Code Duplication
    public function validateMimetypes()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
523
    {
524
        $acceptedMimetypes = $this['acceptedMimetypes'];
525
        if (empty($acceptedMimetypes)) {
526
            // No validation rules = always true
527
            return true;
528
        }
529
530
        $files = $this->parseValAsFileList($this->val());
0 ignored issues
show
Deprecated Code introduced by
The method Charcoal\Property\AbstractProperty::val() has been deprecated.

This method has been deprecated.

Loading history...
531
532
        if (empty($files)) {
533
            return true;
534
        }
535
536
        $valid = true;
537
538
        foreach ($files as $file) {
539
            $mime = $this->getMimetypeFor($file);
540
541
            if ($mime === null) {
542
                $valid = false;
543
544
                $this->validator()->error(sprintf(
545
                    'File [%s] not found or MIME type unrecognizable',
546
                    $file
547
                ), 'acceptedMimetypes');
0 ignored issues
show
Unused Code introduced by
The call to ValidatorInterface::error() has too many arguments starting with 'acceptedMimetypes'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
548
            } elseif (!in_array($mime, $acceptedMimetypes)) {
549
                $valid = false;
550
551
                $this->validator()->error(sprintf(
552
                    'File [%s] has unacceptable MIME type [%s]',
553
                    $file,
554
                    $mime
555
                ), 'acceptedMimetypes');
0 ignored issues
show
Unused Code introduced by
The call to ValidatorInterface::error() has too many arguments starting with 'acceptedMimetypes'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
556
            }
557
        }
558
559
        return $valid;
560
    }
561
562
    /**
563
     * Validates the file sizes for the property's value(s).
564
     *
565
     * @return boolean Returns TRUE if all values are valid.
566
     *     Otherwise, returns FALSE and reports issues.
567
     */
568 View Code Duplication
    public function validateFilesizes()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
569
    {
570
        $maxFilesize = $this['maxFilesize'];
571
        if (empty($maxFilesize)) {
572
            // No max size rule = always true
573
            return true;
574
        }
575
576
        $files = $this->parseValAsFileList($this->val());
0 ignored issues
show
Deprecated Code introduced by
The method Charcoal\Property\AbstractProperty::val() has been deprecated.

This method has been deprecated.

Loading history...
577
578
        if (empty($files)) {
579
            return true;
580
        }
581
582
        $valid = true;
583
584
        foreach ($files as $file) {
585
            $filesize = $this->getFilesizeFor($file);
586
587
            if ($filesize === null) {
588
                $valid = false;
589
590
                $this->validator()->error(sprintf(
591
                    'File [%s] not found or size unknown',
592
                    $file
593
                ), 'maxFilesize');
0 ignored issues
show
Unused Code introduced by
The call to ValidatorInterface::error() has too many arguments starting with 'maxFilesize'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
594
            } elseif (($filesize > $maxFilesize)) {
595
                $valid = false;
596
597
                $this->validator()->error(sprintf(
598
                    'File [%s] exceeds maximum file size [%s]',
599
                    $file,
600
                    $this->formatFilesize($maxFilesize)
601
                ), 'maxFilesize');
0 ignored issues
show
Unused Code introduced by
The call to ValidatorInterface::error() has too many arguments starting with 'maxFilesize'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
602
            }
603
        }
604
605
        return $valid;
606
    }
607
608
    /**
609
     * Parse a multi-dimensional array of value(s) into a single level.
610
     *
611
     * This method flattens a value object that is "l10n" or "multiple".
612
     * Empty or duplicate values are removed.
613
     *
614
     * @param  mixed $value A multi-dimensional variable.
615
     * @return string[] The array of values.
616
     */
617
    public function parseValAsFileList($value)
618
    {
619
        $files = [];
620
621
        if ($value instanceof Translation) {
622
            $value = $value->data();
623
        }
624
625
        $array = $this->parseValAsMultiple($value);
626
        array_walk_recursive($array, function ($item) use (&$files) {
627
            $array = $this->parseValAsMultiple($item);
628
            $files = array_merge($files, $array);
629
        });
630
631
        $files = array_filter($files, function ($file) {
632
            return is_string($file) && isset($file[0]);
633
        });
634
        $files = array_unique($files);
635
        $files = array_values($files);
636
637
        return $files;
638
    }
639
640
    /**
641
     * Get the SQL type (Storage format)
642
     *
643
     * Stored as `VARCHAR` for max_length under 255 and `TEXT` for other, longer strings
644
     *
645
     * @see StorablePropertyTrait::sqlType()
646
     * @return string The SQL type
647
     */
648
    public function sqlType()
649
    {
650
        // Multiple strings are always stored as TEXT because they can hold multiple values
651
        if ($this['multiple']) {
652
            return 'TEXT';
653
        } else {
654
            return 'VARCHAR(255)';
655
        }
656
    }
657
658
    /**
659
     * @see StorablePropertyTrait::sqlPdoType()
660
     * @return integer
661
     */
662
    public function sqlPdoType()
663
    {
664
        return PDO::PARAM_STR;
665
    }
666
667
    /**
668
     * Process file uploads {@see AbstractProperty::save() parsing values}.
669
     *
670
     * @param  mixed $val The value, at time of saving.
671
     * @return mixed
672
     */
673
    public function save($val)
674
    {
675
        if ($val instanceof Translation) {
676
            $values = $val->data();
677
        } else {
678
            $values = $val;
679
        }
680
681
        $uploadedFiles = $this->getUploadedFiles();
682
683
        if ($this['l10n']) {
684
            foreach ($this->translator()->availableLocales() as $lang) {
685
                if (!isset($values[$lang])) {
686
                    $values[$lang] = $this['multiple'] ? [] : '';
687
                }
688
689
                $parsedFiles = [];
690
691
                if (isset($uploadedFiles[$lang])) {
692
                    $parsedFiles = $this->saveFileUploads($uploadedFiles[$lang]);
693
                }
694
695
                if (empty($parsedFiles)) {
696
                    $parsedFiles = $this->saveDataUploads($values[$lang]);
697
                }
698
699
                $values[$lang] = $this->parseSavedValues($parsedFiles, $values[$lang]);
700
            }
701
        } else {
702
            $parsedFiles = [];
703
704
            if (!empty($uploadedFiles)) {
705
                $parsedFiles = $this->saveFileUploads($uploadedFiles);
706
            }
707
708
            if (empty($parsedFiles)) {
709
                $parsedFiles = $this->saveDataUploads($values);
710
            }
711
712
            $values = $this->parseSavedValues($parsedFiles, $values);
713
        }
714
715
        return $values;
716
    }
717
718
    /**
719
     * Process and transfer any data URIs to the filesystem,
720
     * and carry over any pre-processed file paths.
721
     *
722
     * @param  mixed $values One or more data URIs, data entries, or processed file paths.
723
     * @return string|string[] One or more paths to the processed uploaded files.
724
     */
725
    protected function saveDataUploads($values)
726
    {
727
        // Bag value if singular
728
        if (!is_array($values) || isset($values['id'])) {
729
            $values = [ $values ];
730
        }
731
732
        $parsed = [];
733
        foreach ($values as $value) {
734
            if ($this->isDataArr($value) || $this->isDataUri($value)) {
735
                try {
736
                    $path = $this->dataUpload($value);
737 View Code Duplication
                    if ($path !== null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
738
                        $parsed[] = $path;
739
740
                        $this->logger->notice(sprintf(
741
                            'File [%s] uploaded succesfully',
742
                            $path
743
                        ));
744
                    }
745
                } catch (Exception $e) {
746
                    $this->logger->warning(sprintf(
747
                        'Upload error on data URI: %s',
748
                        $e->getMessage()
749
                    ));
750
                }
751
            } elseif (is_string($value) && !empty($value)) {
752
                $parsed[] = $value;
753
            }
754
        }
755
756
        return $parsed;
757
    }
758
759
    /**
760
     * Process and transfer any uploaded files to the filesystem.
761
     *
762
     * @param  mixed $files One or more normalized $_FILE entries.
763
     * @return string[] One or more paths to the processed uploaded files.
764
     */
765
    protected function saveFileUploads($files)
766
    {
767
        // Bag value if singular
768
        if (isset($files['error'])) {
769
            $files = [ $files ];
770
        }
771
772
        $parsed = [];
773
        foreach ($files as $file) {
774
            if (isset($file['error'])) {
775
                try {
776
                    $path = $this->fileUpload($file);
777 View Code Duplication
                    if ($path !== null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
778
                        $parsed[] = $path;
779
780
                        $this->logger->notice(sprintf(
781
                            'File [%s] uploaded succesfully',
782
                            $path
783
                        ));
784
                    }
785
                } catch (Exception $e) {
786
                    $this->logger->warning(sprintf(
787
                        'Upload error on file [%s]: %s',
788
                        $file['name'],
789
                        $e->getMessage()
790
                    ));
791
                }
792
            }
793
        }
794
795
        return $parsed;
796
    }
797
798
    /**
799
     * Finalize any processed files.
800
     *
801
     * @param  mixed $saved   One or more values, at time of saving.
802
     * @param  mixed $default The default value to return.
803
     * @return string|string[] One or more paths to the processed uploaded files.
804
     */
805
    protected function parseSavedValues($saved, $default = null)
806
    {
807
        $values = empty($saved) ? $default : $saved;
808
809
        if ($this['multiple']) {
810
            if (!is_array($values)) {
811
                $values = empty($values) && !is_numeric($values) ? [] : [ $values ];
812
            }
813
        } else {
814
            if (is_array($values)) {
815
                $values = reset($values);
816
            }
817
        }
818
819
        return $values;
820
    }
821
822
    /**
823
     * Upload to filesystem, from data URI.
824
     *
825
     * @param  mixed $data A data URI.
826
     * @throws Exception If data content decoding fails.
827
     * @throws InvalidArgumentException If the input $data is invalid.
828
     * @throws Exception If the upload fails or the $data is bad.
829
     * @return string|null The file path to the uploaded data.
830
     */
831
    public function dataUpload($data)
832
    {
833
        $filename = null;
834
        $contents = false;
835
836
        if (is_array($data)) {
837
            if (!isset($data['id'], $data['name'])) {
838
                throw new InvalidArgumentException(
839
                    '$data as an array MUST contain each of the keys "id" and "name", '.
840
                    'with each represented as a scalar value; one or more were missing or non-array values'
841
                );
842
            }
843
            // retrieve tmp file from temp dir
844
            $tmpDir  = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
845
            $tmpFile = $tmpDir.$data['id'];
846
            if (!file_exists($tmpFile)) {
847
                throw new Exception(sprintf(
848
                    'File %s does not exists',
849
                    $data['id']
850
                ));
851
            }
852
853
            $contents = file_get_contents($tmpFile);
854
855
            if (strlen($data['name']) > 0) {
856
                $filename = $data['name'];
857
            }
858
859
            // delete tmp file
860
            unlink($tmpFile);
861
        } elseif (is_string($data)) {
862
            $contents = file_get_contents($data);
863
        }
864
865
        if ($contents === false) {
866
            throw new Exception(
867
                'File content could not be decoded for data URI'
868
            );
869
        }
870
871
        $info = new finfo(FILEINFO_MIME_TYPE);
872
        $mime = $info->buffer($contents);
873
        if (!$this->isAcceptedMimeType($mime)) {
874
            throw new Exception(sprintf(
875
                'Unacceptable MIME type [%s]',
876
                $mime
877
            ));
878
        }
879
880
        $size = strlen($contents);
881 View Code Duplication
        if (!$this->isAcceptedFilesize($size)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
882
            throw new Exception(sprintf(
883
                'Maximum file size exceeded [%s]',
884
                $this->formatFilesize($this['maxFilesize'])
885
            ));
886
        }
887
888
        if ($filename === null) {
889
            $extension = $this->generateExtensionFromMimeType($mime);
890
            $filename  = $this->generateFilename($extension);
891
        }
892
893
        $targetPath = $this->uploadTarget($filename);
894
895
        $result = file_put_contents($targetPath, $contents);
896
        if ($result === false) {
897
            throw new Exception(sprintf(
898
                'Failed to write file to %s',
899
                $targetPath
900
            ));
901
        }
902
903
        $basePath   = $this->basePath();
904
        $targetPath = str_replace($basePath, '', $targetPath);
905
906
        return $targetPath;
907
    }
908
909
    /**
910
     * Upload to filesystem.
911
     *
912
     * @link https://github.com/slimphp/Slim/blob/3.12.1/Slim/Http/UploadedFile.php
913
     *     Adapted from slim/slim.
914
     *
915
     * @param  array $file A single $_FILES entry.
916
     * @throws InvalidArgumentException If the input $file is invalid.
917
     * @throws Exception If the upload fails or the $file is bad.
918
     * @return string|null The file path to the uploaded file.
919
     */
920
    public function fileUpload(array $file)
921
    {
922
        if (!isset($file['tmp_name'], $file['name'], $file['size'], $file['error'])) {
923
            throw new InvalidArgumentException(
924
                '$file MUST contain each of the keys "tmp_name", "name", "size", and "error", '.
925
                'with each represented as a scalar value; one or more were missing or non-array values'
926
            );
927
        }
928
929
        if ($file['error'] !== UPLOAD_ERR_OK) {
930
            $errorCode = $file['error'];
931
            throw new Exception(
932
                self::ERROR_MESSAGES[$errorCode]
933
            );
934
        }
935
936
        if (!file_exists($file['tmp_name'])) {
937
            throw new Exception(
938
                'File does not exist'
939
            );
940
        }
941
942
        if (!is_uploaded_file($file['tmp_name'])) {
943
            throw new Exception(
944
                'File was not uploaded'
945
            );
946
        }
947
948
        $info = new finfo(FILEINFO_MIME_TYPE);
949
        $mime = $info->file($file['tmp_name']);
950
        if (!$this->isAcceptedMimeType($mime)) {
951
            throw new Exception(sprintf(
952
                'Unacceptable MIME type [%s]',
953
                $mime
954
            ));
955
        }
956
957
        $size = filesize($file['tmp_name']);
958 View Code Duplication
        if (!$this->isAcceptedFilesize($size)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
959
            throw new Exception(sprintf(
960
                'Maximum file size exceeded [%s]',
961
                $this->formatFilesize($this['maxFilesize'])
962
            ));
963
        }
964
965
        $targetPath = $this->uploadTarget($file['name']);
966
967
        $result = move_uploaded_file($file['tmp_name'], $targetPath);
968
        if ($result === false) {
969
            throw new Exception(sprintf(
970
                'Failed to move uploaded file to %s',
971
                $targetPath
972
            ));
973
        }
974
975
        $basePath   = $this->basePath();
976
        $targetPath = str_replace($basePath, '', $targetPath);
977
978
        return $targetPath;
979
    }
980
981
    /**
982
     * Parse the uploaded file path.
983
     *
984
     * This method will create the file's directory path and will sanitize the file's name
985
     * or generate a unique name if none provided (such as data URIs).
986
     *
987
     * @param  string|null $filename Optional. The filename to save as.
988
     *     If NULL, a default filename will be generated.
989
     * @return string
990
     */
991
    public function uploadTarget($filename = null)
992
    {
993
        $this->assertValidUploadPath();
994
995
        $uploadPath = $this->pathFor($this['uploadPath']);
996
997
        if ($filename === null) {
998
            $filename = $this->generateFilename();
999
        } else {
1000
            $filename = $this->sanitizeFilename($filename);
1001
        }
1002
1003
        $targetPath = $uploadPath.'/'.$filename;
1004
1005
        if ($this->fileExists($targetPath)) {
1006
            if ($this['overwrite'] === true) {
1007
                return $targetPath;
1008
            }
1009
1010
            do {
1011
                $targetPath = $uploadPath.'/'.$this->generateUniqueFilename($filename);
1012
            } while ($this->fileExists($targetPath));
1013
        }
1014
1015
        return $targetPath;
1016
    }
1017
1018
    /**
1019
     * Checks whether a file or directory exists.
1020
     *
1021
     * PHP built-in's `file_exists` is only case-insensitive on
1022
     * a case-insensitive filesystem (such as Windows). This method allows
1023
     * to have the same validation across different platforms / filesystems.
1024
     *
1025
     * @param  string  $file            The full file to check.
1026
     * @param  boolean $caseInsensitive Case-insensitive by default.
1027
     * @return boolean
1028
     */
1029
    public function fileExists($file, $caseInsensitive = true)
1030
    {
1031
        $file = (string)$file;
1032
1033
        if (!$this->isAbsolutePath($file)) {
1034
            $file = $this->pathFor($file);
1035
        }
1036
1037
        if (file_exists($file)) {
1038
            return true;
1039
        }
1040
1041
        if ($caseInsensitive === false) {
1042
            return false;
1043
        }
1044
1045
        $files = glob(dirname($file).DIRECTORY_SEPARATOR.'*', GLOB_NOSORT);
1046
        if ($files) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $files of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1047
            $pattern = preg_quote($file, '#');
1048
            foreach ($files as $f) {
1049
                if (preg_match("#{$pattern}#i", $f)) {
1050
                    return true;
1051
                }
1052
            }
1053
        }
1054
1055
        return false;
1056
    }
1057
1058
    /**
1059
     * Sanitize a filename by removing characters from a blacklist and escaping dot.
1060
     *
1061
     * @param  string $filename The filename to sanitize.
1062
     * @throws Exception If the filename is invalid.
1063
     * @return string The sanitized filename.
1064
     */
1065
    public function sanitizeFilename($filename)
1066
    {
1067
        // Remove blacklisted caharacters
1068
        $blacklist = [ '/', '\\', '\0', '*', ':', '?', '"', '<', '>', '|', '#', '&', '!', '`', ' ' ];
1069
        $filename  = str_replace($blacklist, '_', (string)$filename);
1070
1071
        // Avoid hidden file or trailing dot
1072
        $filename = trim($filename, '.');
1073
1074
        if (strlen($filename) === 0) {
1075
            throw new Exception(
1076
                'Bad file name after sanitization'
1077
            );
1078
        }
1079
1080
        return $filename;
1081
    }
1082
1083
    /**
1084
     * Render the given file to the given pattern.
1085
     *
1086
     * This method does not rename the given path.
1087
     *
1088
     * @uses   strtr() To replace tokens in the form `{{foobar}}`.
1089
     * @param  string         $from The string being rendered.
1090
     * @param  string         $to   The pattern replacing $from.
1091
     * @param  array|callable $args Extra rename tokens.
1092
     * @throws InvalidArgumentException If the given arguments are invalid.
1093
     * @throws UnexpectedValueException If the renaming failed.
1094
     * @return string Returns the rendered target.
1095
     */
1096
    public function renderFileRenamePattern($from, $to, $args = null)
1097
    {
1098
        if (!is_string($from)) {
1099
            throw new InvalidArgumentException(sprintf(
1100
                'The target to rename must be a string, received %s',
1101
                (is_object($from) ? get_class($from) : gettype($from))
1102
            ));
1103
        }
1104
1105
        if (!is_string($to)) {
1106
            throw new InvalidArgumentException(sprintf(
1107
                'The rename pattern must be a string, received %s',
1108
                (is_object($to) ? get_class($to) : gettype($to))
1109
            ));
1110
        }
1111
1112
        $info = pathinfo($from);
1113
        $args = $this->renamePatternArgs($info, $args);
1114
1115
        $to = strtr($to, $args);
1116
        if (strpos($to, '{{') !== false) {
1117
            preg_match_all('~\{\{\s*(.*?)\s*\}\}~i', $to, $matches);
1118
1119
            throw new UnexpectedValueException(sprintf(
1120
                'The rename pattern failed. Leftover tokens found: %s',
1121
                implode(', ', $matches[1])
1122
            ));
1123
        }
1124
1125
        $to = str_replace($info['basename'], $to, $from);
1126
1127
        return $to;
1128
    }
1129
1130
    /**
1131
     * Generate a new filename from the property.
1132
     *
1133
     * @param  string|null $extension An extension to append to the generated filename.
1134
     * @return string
1135
     */
1136
    public function generateFilename($extension = null)
1137
    {
1138
        $filename = $this->sanitizeFilename($this['fallbackFilename']);
1139
        $filename = $filename.' '.date('Y-m-d\TH-i-s');
1140
1141
        if ($extension !== null) {
1142
            return $filename.'.'.$extension;
1143
        }
1144
1145
        return $filename;
1146
    }
1147
1148
    /**
1149
     * Generate a unique filename.
1150
     *
1151
     * @param  string|array $filename The filename to alter.
1152
     * @throws InvalidArgumentException If the given filename is invalid.
1153
     * @return string
1154
     */
1155
    public function generateUniqueFilename($filename)
1156
    {
1157
        if (is_string($filename)) {
1158
            $info = pathinfo($filename);
1159
        } else {
1160
            $info = $filename;
1161
        }
1162
1163
        if (!isset($info['filename']) || strlen($info['filename']) === 0) {
1164
            throw new InvalidArgumentException(sprintf(
1165
                'File must be a string [file path] or an array [pathfino()], received %s',
1166
                (is_object($filename) ? get_class($filename) : gettype($filename))
1167
            ));
1168
        }
1169
1170
        $filename = $info['filename'].'-'.uniqid();
1171
1172
        if (isset($info['extension']) && strlen($info['extension']) > 0) {
1173
            $filename .= '.'.$info['extension'];
1174
        }
1175
1176
        return $filename;
1177
    }
1178
1179
    /**
1180
     * Generate the file extension from the property value.
1181
     *
1182
     * @todo Refactor to support multilingual/multiple files.
1183
     *
1184
     * @return string Returns the file extension based on the MIME type for the first value.
1185
     */
1186
    public function generateExtension()
1187
    {
1188
        $type = $this->getMimetype();
1189
1190
        return $this->resolveExtensionFromMimeType($type);
1191
    }
1192
1193
    /**
1194
     * Generate a file extension from the given file path.
1195
     *
1196
     * @param  string $file The file to parse.
1197
     * @return string|null The extension based on the file's MIME type.
1198
     */
1199
    public function generateExtensionFromFile($file)
1200
    {
1201
        if ($this->hasAcceptedMimetypes()) {
1202
            $type = $this->getMimetypeFor($file);
1203
1204
            return $this->resolveExtensionFromMimeType($type);
1205
        }
1206
1207
        if (!is_string($file) || !defined('FILEINFO_EXTENSION')) {
1208
            return null;
1209
        }
1210
1211
        // PHP 7.2
1212
        $info = new finfo(FILEINFO_EXTENSION);
1213
        $ext  = $info->file($file);
1214
1215
        if ($ext === '???') {
1216
            return null;
1217
        }
1218
1219
        if (strpos($ext, '/') !== false) {
1220
            $ext = explode('/', $ext);
1221
            $ext = reset($ext);
1222
        }
1223
1224
        return $ext;
1225
    }
1226
1227
    /**
1228
     * Generate a file extension from the given MIME type.
1229
     *
1230
     * @param  string $type The MIME type to parse.
1231
     * @return string|null The extension based on the MIME type.
1232
     */
1233
    public function generateExtensionFromMimeType($type)
1234
    {
1235
        if (in_array($type, $this->getAcceptedMimetypes())) {
1236
            return $this->resolveExtensionFromMimeType($type);
1237
        }
1238
1239
        return null;
1240
    }
1241
1242
    /**
1243
     * Resolve the file extension from the given MIME type.
1244
     *
1245
     * This method should be overriden to provide available extensions.
1246
     *
1247
     * @param  string $type The MIME type to resolve.
1248
     * @return string|null The extension based on the MIME type.
1249
     */
1250
    protected function resolveExtensionFromMimeType($type)
1251
    {
1252
        switch ($type) {
1253
            case 'text/plain':
1254
                return 'txt';
1255
        }
1256
1257
        return null;
1258
    }
1259
1260
    /**
1261
     * @param  mixed $fallback The fallback filename.
1262
     * @return self
1263
     */
1264
    public function setFallbackFilename($fallback)
1265
    {
1266
        $this->fallbackFilename = $this->translator()->translation($fallback);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->translator()->translation($fallback) can also be of type object<Charcoal\Translator\Translation>. However, the property $fallbackFilename is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
1267
        return $this;
1268
    }
1269
1270
    /**
1271
     * @return Translation|null
1272
     */
1273
    public function getFallbackFilename()
1274
    {
1275
        if ($this->fallbackFilename === null) {
1276
            return $this['label'];
1277
        }
1278
1279
        return $this->fallbackFilename;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this->fallbackFilename; (string) is incompatible with the return type documented by Charcoal\Property\FilePr...ty::getFallbackFilename of type Charcoal\Translator\Translation|null.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
1280
    }
1281
1282
    /**
1283
     * @return string
1284
     */
1285
    public function getFilesystem()
1286
    {
1287
        return $this->filesystem;
1288
    }
1289
1290
    /**
1291
     * @param string $filesystem The file system.
1292
     * @return self
1293
     */
1294
    public function setFilesystem($filesystem)
1295
    {
1296
        $this->filesystem = $filesystem;
1297
1298
        return $this;
1299
    }
1300
1301
    /**
1302
     * Inject dependencies from a DI Container.
1303
     *
1304
     * @param  Container $container A dependencies container instance.
1305
     * @return void
1306
     */
1307
    protected function setDependencies(Container $container)
1308
    {
1309
        parent::setDependencies($container);
1310
1311
        $this->basePath   = $container['config']['base_path'];
1312
        $this->publicPath = $container['config']['public_path'];
1313
    }
1314
1315
    /**
1316
     * Retrieve the base path to the storage directory.
1317
     *
1318
     * @return string
1319
     */
1320
    protected function basePath()
1321
    {
1322
        if ($this['publicAccess']) {
1323
            return $this->publicPath;
1324
        }
1325
1326
        return $this->basePath;
1327
    }
1328
1329
    /**
1330
     * Build the path for a named route including the base path.
1331
     *
1332
     * The {@see self::basePath() base path} will be prepended to the given $path.
1333
     *
1334
     * If the given $path does not start with the {@see self::getUploadPath() upload path},
1335
     * it will be prepended.
1336
     *
1337
     * @param  string $path The end path.
1338
     * @return string
1339
     */
1340
    protected function pathFor($path)
1341
    {
1342
        $path       = trim($path, '/');
1343
        $uploadPath = trim($this['uploadPath'], '/');
1344
        $basePath   = rtrim($this->basePath(), '/');
1345
1346
        if (strpos($path, $uploadPath) !== 0) {
1347
            $basePath .= '/'.$uploadPath;
1348
        }
1349
1350
        return $basePath.'/'.$path;
1351
    }
1352
1353
    /**
1354
     * Attempts to create the upload path.
1355
     *
1356
     * @throws Exception If the upload path is unavailable.
1357
     * @return void
1358
     */
1359
    protected function assertValidUploadPath()
1360
    {
1361
        $uploadPath = $this->pathFor($this['uploadPath']);
1362
1363
        if (!file_exists($uploadPath)) {
1364
            $this->logger->debug(sprintf(
1365
                '[%s] Upload directory [%s] does not exist; attempting to create path',
1366
                [ get_called_class().'::'.__FUNCTION__ ],
1367
                $uploadPath
1368
            ));
1369
1370
            mkdir($uploadPath, 0777, true);
1371
        }
1372
1373
        if (!is_writable($uploadPath)) {
1374
            throw new Exception(sprintf(
1375
                'Upload directory [%s] is not writeable',
1376
                $uploadPath
1377
            ));
1378
        }
1379
    }
1380
1381
    /**
1382
     * Converts a php.ini notation for size to an integer.
1383
     *
1384
     * @param  mixed $size A php.ini notation for size.
1385
     * @throws InvalidArgumentException If the given parameter is invalid.
1386
     * @return integer Returns the size in bytes.
1387
     */
1388
    protected function parseIniSize($size)
1389
    {
1390
        if (is_numeric($size)) {
1391
            return $size;
1392
        }
1393
1394
        if (!is_string($size)) {
1395
            throw new InvalidArgumentException(
1396
                'Size must be an integer (in bytes, e.g.: 1024) or a string (e.g.: 1M)'
1397
            );
1398
        }
1399
1400
        $quant = 'bkmgtpezy';
1401
        $unit = preg_replace('/[^'.$quant.']/i', '', $size);
1402
        $size = preg_replace('/[^0-9\.]/', '', $size);
1403
1404
        if ($unit) {
1405
            $size = ($size * pow(1024, stripos($quant, $unit[0])));
1406
        }
1407
1408
        return round($size);
1409
    }
1410
1411
    /**
1412
     * Determine if the given MIME type is acceptable.
1413
     *
1414
     * @param  string   $type     A MIME type.
1415
     * @param  string[] $accepted One or many acceptable MIME types.
1416
     *     Defaults to the property's "acceptedMimetypes".
1417
     * @return boolean Returns TRUE if the MIME type is acceptable.
1418
     *     Otherwise, returns FALSE.
1419
     */
1420
    protected function isAcceptedMimeType($type, array $accepted = null)
1421
    {
1422
        if ($accepted === null) {
1423
            $accepted = $this['acceptedMimetypes'];
1424
        }
1425
1426
        if (empty($accepted)) {
1427
            return true;
1428
        }
1429
1430
        return in_array($type, $accepted);
1431
    }
1432
1433
    /**
1434
     * Determine if the given file size is acceptable.
1435
     *
1436
     * @param  integer $size Number of bytes.
1437
     * @param  integer $max  The maximum number of bytes allowed.
1438
     *     Defaults to the property's "maxFilesize".
1439
     * @return boolean Returns TRUE if the size is acceptable.
1440
     *     Otherwise, returns FALSE.
1441
     */
1442
    protected function isAcceptedFilesize($size, $max = null)
1443
    {
1444
        if ($max === null) {
1445
            $max = $this['maxFilesize'];
1446
        }
1447
1448
        if (empty($max)) {
1449
            return true;
1450
        }
1451
1452
        return ($size <= $max);
1453
    }
1454
1455
    /**
1456
     * Determine if the given file path is an absolute path.
1457
     *
1458
     * Note: Adapted from symfony\filesystem.
1459
     *
1460
     * @see https://github.com/symfony/symfony/blob/v3.2.2/LICENSE
1461
     *
1462
     * @param  string $file A file path.
1463
     * @return boolean Returns TRUE if the given path is absolute. Otherwise, returns FALSE.
1464
     */
1465
    protected function isAbsolutePath($file)
1466
    {
1467
        $file = (string)$file;
1468
1469
        return strspn($file, '/\\', 0, 1)
1470
            || (strlen($file) > 3
1471
                && ctype_alpha($file[0])
1472
                && substr($file, 1, 1) === ':'
1473
                && strspn($file, '/\\', 2, 1))
1474
            || null !== parse_url($file, PHP_URL_SCHEME);
1475
    }
1476
1477
    /**
1478
     * Determine if the given value is a data URI.
1479
     *
1480
     * @param  mixed $val The value to check.
1481
     * @return boolean
1482
     */
1483
    protected function isDataUri($val)
1484
    {
1485
        return is_string($val) && preg_match('/^data:/i', $val);
1486
    }
1487
1488
    /**
1489
     * Determine if the given value is a data array.
1490
     *
1491
     * @param  mixed $val The value to check.
1492
     * @return boolean
1493
     */
1494
    protected function isDataArr($val)
1495
    {
1496
        return is_array($val) && isset($val['id']);
1497
    }
1498
1499
    /**
1500
     * Retrieve the rename pattern tokens for the given file.
1501
     *
1502
     * @param  string|array   $path The string to be parsed or an associative array of information about the file.
1503
     * @param  array|callable $args Extra rename tokens.
1504
     * @throws InvalidArgumentException If the given arguments are invalid.
1505
     * @throws UnexpectedValueException If the given path is invalid.
1506
     * @return string Returns the rendered target.
1507
     */
1508
    private function renamePatternArgs($path, $args = null)
1509
    {
1510
        if (!is_string($path) && !is_array($path)) {
1511
            throw new InvalidArgumentException(sprintf(
1512
                'The target must be a string or an array from [pathfino()], received %s',
1513
                (is_object($path) ? get_class($path) : gettype($path))
1514
            ));
1515
        }
1516
1517
        if (is_string($path)) {
1518
            $info = pathinfo($path);
1519
        } else {
1520
            $info = $path;
1521
        }
1522
1523
        if (!isset($info['basename']) || $info['basename'] === '') {
1524
            throw new UnexpectedValueException(
1525
                'The basename is missing from the target'
1526
            );
1527
        }
1528
1529
        if (!isset($info['filename']) || $info['filename'] === '') {
1530
            throw new UnexpectedValueException(
1531
                'The filename is missing from the target'
1532
            );
1533
        }
1534
1535
        if (!isset($info['extension'])) {
1536
            $info['extension'] = '';
1537
        }
1538
1539
        $defaults = [
1540
            '{{property}}'  => $this->ident(),
1541
            '{{label}}'     => $this['label'],
1542
            '{{fallback}}'  => $this['fallbackFilename'],
1543
            '{{extension}}' => $info['extension'],
1544
            '{{basename}}'  => $info['basename'],
1545
            '{{filename}}'  => $info['filename'],
1546
        ];
1547
1548
        if ($args === null) {
1549
            $args = $defaults;
1550
        } else {
1551
            if (is_callable($args)) {
1552
                /**
1553
                 * Rename Arguments Callback Routine
1554
                 *
1555
                 * @param  array             $info Information about the file path from {@see pathinfo()}.
1556
                 * @param  PropertyInterface $prop The related image property.
1557
                 * @return array
1558
                 */
1559
                $args = $args($info, $this);
1560
            }
1561
1562
            if (is_array($args)) {
1563
                $args = array_replace($defaults, $args);
1564
            } else {
1565
                throw new InvalidArgumentException(sprintf(
1566
                    'Arguments must be an array or a callable that returns an array, received %s',
1567
                    (is_object($args) ? get_class($args) : gettype($args))
1568
                ));
1569
            }
1570
        }
1571
1572
        return $args;
1573
    }
1574
1575
    /**
1576
     * Retrieve normalized file upload data for this property.
1577
     *
1578
     * @return array A tree of normalized $_FILE entries.
1579
     */
1580
    public function getUploadedFiles()
1581
    {
1582
        $propIdent = $this->ident();
1583
1584
        $filterErrNoFile = function (array $file) {
1585
            return $file['error'] !== UPLOAD_ERR_NO_FILE;
1586
        };
1587
        $uploadedFiles = static::parseUploadedFiles($_FILES, $filterErrNoFile, $propIdent);
1588
1589
        return $uploadedFiles;
1590
    }
1591
1592
    /**
1593
     * Parse a non-normalized, i.e. $_FILES superglobal, tree of uploaded file data.
1594
     *
1595
     * @link https://github.com/slimphp/Slim/blob/3.12.1/Slim/Http/UploadedFile.php
1596
     *     Adapted from slim/slim.
1597
     *
1598
     * @todo Add support for "dot" notation on $searchKey.
1599
     *
1600
     * @param  array    $uploadedFiles  The non-normalized tree of uploaded file data.
1601
     * @param  callable $filterCallback If specified, the callback function to used to filter files.
1602
     * @param  mixed    $searchKey      If specified, then only top-level keys containing these values are returned.
1603
     * @return array A tree of normalized $_FILE entries.
1604
     */
1605
    public static function parseUploadedFiles(array $uploadedFiles, callable $filterCallback = null, $searchKey = null)
1606
    {
1607
        if ($searchKey !== null) {
1608
            if (is_array($searchKey)) {
1609
                $uploadedFiles = array_intersect_key($uploadedFiles, array_flip($searchKey));
1610
                return static::parseUploadedFiles($uploadedFiles, $filterCallback);
1611
            }
1612
1613
            if (isset($uploadedFiles[$searchKey])) {
1614
                $uploadedFiles = [
1615
                    $searchKey => $uploadedFiles[$searchKey],
1616
                ];
1617
                $parsedFiles = static::parseUploadedFiles($uploadedFiles, $filterCallback);
1618
                if (isset($parsedFiles[$searchKey])) {
1619
                    return $parsedFiles[$searchKey];
1620
                }
1621
            }
1622
1623
            return [];
1624
        }
1625
1626
        $parsedFiles = [];
1627
        foreach ($uploadedFiles as $field => $uploadedFile) {
1628
            if (!isset($uploadedFile['error'])) {
1629
                if (is_array($uploadedFile)) {
1630
                    $subArray = static::parseUploadedFiles($uploadedFile, $filterCallback);
1631 View Code Duplication
                    if (!empty($subArray)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1632
                        if (!isset($parsedFiles[$field])) {
1633
                            $parsedFiles[$field] = [];
1634
                        }
1635
1636
                        $parsedFiles[$field] = $subArray;
1637
                    }
1638
                }
1639
                continue;
1640
            }
1641
1642
            if (!is_array($uploadedFile['error'])) {
1643
                if ($filterCallback === null || $filterCallback($uploadedFile, $field) === true) {
1644
                    if (!isset($parsedFiles[$field])) {
1645
                        $parsedFiles[$field] = [];
1646
                    }
1647
1648
                    $parsedFiles[$field] = [
1649
                        'tmp_name' => $uploadedFile['tmp_name'],
1650
                        'name'     => isset($uploadedFile['name']) ? $uploadedFile['name'] : null,
1651
                        'type'     => isset($uploadedFile['type']) ? $uploadedFile['type'] : null,
1652
                        'size'     => isset($uploadedFile['size']) ? $uploadedFile['size'] : null,
1653
                        'error'    => $uploadedFile['error'],
1654
                    ];
1655
                }
1656
            } else {
1657
                $subArray = [];
1658
                foreach ($uploadedFile['error'] as $fileIdx => $error) {
1659
                    // normalise subarray and re-parse to move the input's keyname up a level
1660
                    $subArray[$fileIdx] = [
1661
                        'tmp_name' => $uploadedFile['tmp_name'][$fileIdx],
1662
                        'name'     => $uploadedFile['name'][$fileIdx],
1663
                        'type'     => $uploadedFile['type'][$fileIdx],
1664
                        'size'     => $uploadedFile['size'][$fileIdx],
1665
                        'error'    => $uploadedFile['error'][$fileIdx],
1666
                    ];
1667
1668
                    $subArray = static::parseUploadedFiles($subArray, $filterCallback);
1669 View Code Duplication
                    if (!empty($subArray)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
1670
                        if (!isset($parsedFiles[$field])) {
1671
                            $parsedFiles[$field] = [];
1672
                        }
1673
1674
                        $parsedFiles[$field] = $subArray;
1675
                    }
1676
                }
1677
            }
1678
        }
1679
1680
        return $parsedFiles;
1681
    }
1682
1683
    /**
1684
     * Normalize a file path string so that it can be checked safely.
1685
     *
1686
     * Attempt to avoid invalid encoding bugs by transcoding the path. Then
1687
     * remove any unnecessary path components including '.', '..' and ''.
1688
     *
1689
     * @link https://gist.github.com/thsutton/772287
1690
     *
1691
     * @param  string $path     The path to normalise.
1692
     * @param  string $encoding The name of the path iconv() encoding.
1693
     * @return string The path, normalised.
1694
     */
1695
    public static function normalizePath($path, $encoding = 'UTF-8')
1696
    {
1697
        $key = $path;
1698
1699
        if (isset(static::$normalizePathCache[$key])) {
1700
            return static::$normalizePathCache[$key];
1701
        }
1702
1703
        // Attempt to avoid path encoding problems.
1704
        $path = iconv($encoding, $encoding.'//IGNORE//TRANSLIT', $path);
1705
1706
        if (strpos($path, '..') !== false || strpos($path, './') !== false) {
1707
            // Process the components
1708
            $parts = explode('/', $path);
1709
            $safe = [];
1710
            foreach ($parts as $idx => $part) {
1711
                if ((empty($part) && !is_numeric($part)) || ($part === '.')) {
1712
                    continue;
1713
                } elseif ($part === '..') {
1714
                    array_pop($safe);
1715
                    continue;
1716
                } else {
1717
                    $safe[] = $part;
1718
                }
1719
            }
1720
1721
            // Return the "clean" path
1722
            $path = implode(DIRECTORY_SEPARATOR, $safe);
1723
1724
            if ($key[0] === '/' && $path[0] !== '/') {
1725
                $path = '/'.$path;
1726
            }
1727
        }
1728
1729
        static::$normalizePathCache[$key] = $path;
1730
1731
        return static::$normalizePathCache[$key];
1732
    }
1733
}
1734