Test Setup Failed
Push — master ( 23a388...e34058 )
by Chauncey
02:03
created

FileProperty::normalizePath()   B

Complexity

Conditions 11
Paths 4

Size

Total Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 38
rs 7.3166
c 0
b 0
f 0
cc 11
nc 4
nop 2

How to fix   Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
namespace 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
     * The filesystem to use while uploading a file.
106
     *
107
     * @var string
108
     */
109
    private $filesystem = self::DEFAULT_FILESYSTEM;
110
111
    /**
112
     * Holds a list of all normalized paths.
113
     *
114
     * @var string[]
115
     */
116
    protected static $normalizePathCache = [];
117
118
    /**
119
     * @return string
120
     */
121
    public function type()
122
    {
123
        return 'file';
124
    }
125
126
    /**
127
     * Set whether uploaded files should be publicly available.
128
     *
129
     * @param  boolean $public Whether uploaded files should be accessible (TRUE) or not (FALSE) from the web root.
130
     * @return self
131
     */
132
    public function setPublicAccess($public)
133
    {
134
        $this->publicAccess = !!$public;
135
136
        return $this;
137
    }
138
139
    /**
140
     * Determine if uploaded files should be publicly available.
141
     *
142
     * @return boolean
143
     */
144
    public function getPublicAccess()
145
    {
146
        return $this->publicAccess;
147
    }
148
149
    /**
150
     * Set the destination (directory) where uploaded files are stored.
151
     *
152
     * The path must be relative to the {@see self::basePath()},
153
     *
154
     * @param  string $path The destination directory, relative to project's root.
155
     * @throws InvalidArgumentException If the path is not a string.
156
     * @return self
157
     */
158
    public function setUploadPath($path)
159
    {
160
        if (!is_string($path)) {
161
            throw new InvalidArgumentException(
162
                'Upload path must be a string'
163
            );
164
        }
165
166
        // Sanitize upload path (force trailing slash)
167
        $this->uploadPath = rtrim($path, '/').'/';
168
169
        return $this;
170
    }
171
172
    /**
173
     * Retrieve the destination for the uploaded file(s).
174
     *
175
     * @return string
176
     */
177
    public function getUploadPath()
178
    {
179
        return $this->uploadPath;
180
    }
181
182
    /**
183
     * Set whether existing destinations should be overwritten.
184
     *
185
     * @param  boolean $overwrite Whether existing destinations should be overwritten (TRUE) or not (FALSE).
186
     * @return self
187
     */
188
    public function setOverwrite($overwrite)
189
    {
190
        $this->overwrite = !!$overwrite;
191
192
        return $this;
193
    }
194
195
    /**
196
     * Determine if existing destinations should be overwritten.
197
     *
198
     * @return boolean
199
     */
200
    public function getOverwrite()
201
    {
202
        return $this->overwrite;
203
    }
204
205
    /**
206
     * @param  string[] $mimetypes The accepted mimetypes.
207
     * @return self
208
     */
209
    public function setAcceptedMimetypes(array $mimetypes)
210
    {
211
        $this->acceptedMimetypes = $mimetypes;
212
213
        return $this;
214
    }
215
216
    /**
217
     * @return string[]
218
     */
219
    public function getAcceptedMimetypes()
220
    {
221
        return $this->acceptedMimetypes;
222
    }
223
224
    /**
225
     * Set the MIME type.
226
     *
227
     * @param  mixed $type The file MIME type.
228
     * @throws InvalidArgumentException If the MIME type argument is not a string.
229
     * @return FileProperty Chainable
230
     */
231
    public function setMimetype($type)
232
    {
233
        if ($type === null || $type === false) {
234
            $this->mimetype = null;
235
236
            return $this;
237
        }
238
239
        if (!is_string($type)) {
240
            throw new InvalidArgumentException(
241
                'Mimetype must be a string'
242
            );
243
        }
244
245
        $this->mimetype = $type;
246
247
        return $this;
248
    }
249
250
    /**
251
     * Retrieve the MIME type.
252
     *
253
     * @return string
254
     */
255
    public function getMimetype()
256
    {
257
        if (!$this->mimetype) {
258
            $val = $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...
259
260
            if (!$val) {
261
                return '';
262
            }
263
264
            $this->setMimetype($this->getMimetypeFor(strval($val)));
265
        }
266
267
        return $this->mimetype;
268
    }
269
270
    /**
271
     * Alias of {@see self::getMimetype()}.
272
     *
273
     * @return string
274
     */
275
    public function mimetype()
276
    {
277
        return $this->getMimetype();
278
    }
279
280
    /**
281
     * Extract the MIME type from the given file.
282
     *
283
     * @uses   finfo
284
     * @param  string $file The file to check.
285
     * @return string|false Returns the given file's MIME type or FALSE if an error occurred.
286
     */
287
    public function getMimetypeFor($file)
288
    {
289
        $info = new finfo(FILEINFO_MIME_TYPE);
290
291
        return $info->file($file);
292
    }
293
294
    /**
295
     * Alias of {@see self::getMimetypeFor()}.
296
     *
297
     * @param  string $file The file to check.
298
     * @return string|false
299
     */
300
    public function mimetypeFor($file)
301
    {
302
        return $this->getMimetypeFor($file);
303
    }
304
305
    /**
306
     * Set the maximium size accepted for an uploaded files.
307
     *
308
     * @param  string|integer $size The maximum file size allowed, in bytes.
309
     * @throws InvalidArgumentException If the size argument is not an integer.
310
     * @return FileProperty Chainable
311
     */
312
    public function setMaxFilesize($size)
313
    {
314
        $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...
315
316
        return $this;
317
    }
318
319
    /**
320
     * Retrieve the maximum size accepted for uploaded files.
321
     *
322
     * If null or 0, then no limit. Defaults to 128 MB.
323
     *
324
     * @return integer
325
     */
326
    public function getMaxFilesize()
327
    {
328
        if (!isset($this->maxFilesize)) {
329
            return $this->maxFilesizeAllowedByPhp();
330
        }
331
332
        return $this->maxFilesize;
333
    }
334
335
    /**
336
     * Retrieve the maximum size (in bytes) allowed for an uploaded file
337
     * as configured in {@link http://php.net/manual/en/ini.php `php.ini`}.
338
     *
339
     * @param string|null $iniDirective If $iniDirective is provided, then it is filled with
340
     *     the name of the PHP INI directive corresponding to the maximum size allowed.
341
     * @return integer
342
     */
343
    public function maxFilesizeAllowedByPhp(&$iniDirective = null)
344
    {
345
        $postMaxSize = $this->parseIniSize(ini_get('post_max_size'));
346
        $uploadMaxFilesize = $this->parseIniSize(ini_get('upload_max_filesize'));
347
348
        if ($postMaxSize < $uploadMaxFilesize) {
349
            $iniDirective = 'post_max_size';
350
351
            return $postMaxSize;
352
        } else {
353
            $iniDirective = 'upload_max_filesize';
354
355
            return $uploadMaxFilesize;
356
        }
357
    }
358
359
    /**
360
     * @param  integer $size The file size, in bytes.
361
     * @throws InvalidArgumentException If the size argument is not an integer.
362
     * @return FileProperty Chainable
363
     */
364
    public function setFilesize($size)
365
    {
366
        if (!is_int($size)) {
367
            throw new InvalidArgumentException(
368
                'Filesize must be an integer, in bytes.'
369
            );
370
        }
371
        $this->filesize = $size;
372
373
        return $this;
374
    }
375
376
    /**
377
     * @return integer
378
     */
379
    public function getFilesize()
380
    {
381
        if (!$this->filesize) {
382
            $val = $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...
383
            if (!$val || !file_exists($val) || !is_readable($val)) {
384
                return 0;
385
            } else {
386
                $this->filesize = filesize($val);
387
            }
388
        }
389
390
        return $this->filesize;
391
    }
392
393
    /**
394
     * Alias of {@see self::getFilesize()}.
395
     *
396
     * @return integer
397
     */
398
    public function filesize()
399
    {
400
        return $this->getFilesize();
401
    }
402
403
    /**
404
     * @return array
405
     */
406
    public function validationMethods()
407
    {
408
        $parentMethods = parent::validationMethods();
409
410
        return array_merge($parentMethods, [
411
            'acceptedMimetypes',
412
            'maxFilesize',
413
        ]);
414
    }
415
416
    /**
417
     * @return boolean
418
     */
419
    public function validateAcceptedMimetypes()
420
    {
421
        $acceptedMimetypes = $this['acceptedMimetypes'];
422
        if (empty($acceptedMimetypes)) {
423
            // No validation rules = always true
424
            return true;
425
        }
426
427
        if ($this->mimetype) {
428
            $mimetype = $this->mimetype;
429
        } else {
430
            $val = $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...
431
            if (!$val) {
432
                return true;
433
            }
434
            $mimetype = $this->getMimetypeFor($val);
435
        }
436
        $valid = false;
437
        foreach ($acceptedMimetypes as $m) {
438
            if ($m == $mimetype) {
439
                $valid = true;
440
                break;
441
            }
442
        }
443
        if (!$valid) {
444
            $this->validator()->error('Accepted mimetypes error', '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...
445
        }
446
447
        return $valid;
448
    }
449
450
    /**
451
     * @return boolean
452
     */
453
    public function validateMaxFilesize()
454
    {
455
        $maxFilesize = $this['maxFilesize'];
456
        if ($maxFilesize == 0) {
457
            // No max size rule = always true
458
            return true;
459
        }
460
461
        $filesize = $this->filesize();
462
        $valid = ($filesize <= $maxFilesize);
463
        if (!$valid) {
464
            $this->validator()->error('Max filesize error', '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...
465
        }
466
467
        return $valid;
468
    }
469
470
    /**
471
     * Get the SQL type (Storage format)
472
     *
473
     * Stored as `VARCHAR` for max_length under 255 and `TEXT` for other, longer strings
474
     *
475
     * @see StorablePropertyTrait::sqlType()
476
     * @return string The SQL type
477
     */
478
    public function sqlType()
479
    {
480
        // Multiple strings are always stored as TEXT because they can hold multiple values
481
        if ($this['multiple']) {
482
            return 'TEXT';
483
        } else {
484
            return 'VARCHAR(255)';
485
        }
486
    }
487
488
    /**
489
     * @see StorablePropertyTrait::sqlPdoType()
490
     * @return integer
491
     */
492
    public function sqlPdoType()
493
    {
494
        return PDO::PARAM_STR;
495
    }
496
497
    /**
498
     * Process file uploads {@see AbstractProperty::save() parsing values}.
499
     *
500
     * @param  mixed $val The value, at time of saving.
501
     * @return mixed
502
     */
503
    public function save($val)
504
    {
505
        if ($val instanceof Translation) {
506
            $values = $val->data();
507
        } else {
508
            $values = $val;
509
        }
510
511
        $uploadedFiles = $this->getUploadedFiles();
512
513
        if ($this['l10n']) {
514
            foreach ($this->translator()->availableLocales() as $lang) {
515
                if (!isset($values[$lang])) {
516
                    $values[$lang] = $this['multiple'] ? [] : '';
517
                }
518
519
                $parsedFiles = [];
520
521
                if (isset($uploadedFiles[$lang])) {
522
                    $parsedFiles = $this->saveFileUploads($uploadedFiles[$lang]);
523
                }
524
525
                if (empty($parsedFiles)) {
526
                    $parsedFiles = $this->saveDataUploads($values[$lang]);
527
                }
528
529
                $values[$lang] = $this->parseSavedValues($parsedFiles, $values[$lang]);
530
            }
531
        } else {
532
            $parsedFiles = [];
533
534
            if (!empty($uploadedFiles)) {
535
                $parsedFiles = $this->saveFileUploads($uploadedFiles);
536
            }
537
538
            if (empty($parsedFiles)) {
539
                $parsedFiles = $this->saveDataUploads($values);
540
            }
541
542
            $values = $this->parseSavedValues($parsedFiles, $values);
543
        }
544
545
        return $values;
546
    }
547
548
    /**
549
     * Process and transfer any data URIs to the filesystem,
550
     * and carry over any pre-processed file paths.
551
     *
552
     * @param  mixed $values One or more data URIs, data entries, or processed file paths.
553
     * @return string|string[] One or more paths to the processed uploaded files.
554
     */
555
    protected function saveDataUploads($values)
556
    {
557
        // Bag value if singular
558
        if (!is_array($values) || isset($values['id'])) {
559
            $values = [ $values ];
560
        }
561
562
        $parsed = [];
563
        foreach ($values as $value) {
564
            if ($this->isDataArr($value) || $this->isDataUri($value)) {
565
                $path = $this->dataUpload($value);
566
                if ($path !== null) {
567
                    $parsed[] = $path;
568
                }
569
            } elseif (is_string($value) && !empty($value)) {
570
                $parsed[] = $value;
571
            }
572
        }
573
574
        return $parsed;
575
    }
576
577
    /**
578
     * Process and transfer any uploaded files to the filesystem.
579
     *
580
     * @param  mixed $files One or more normalized $_FILE entries.
581
     * @return string[] One or more paths to the processed uploaded files.
582
     */
583
    protected function saveFileUploads($files)
584
    {
585
        // Bag value if singular
586
        if (isset($files['error'])) {
587
            $files = [ $files ];
588
        }
589
590
        $parsed = [];
591
        foreach ($files as $file) {
592
            if (isset($file['error'])) {
593
                $path = $this->fileUpload($file);
594
                if ($path !== null) {
595
                    $parsed[] = $path;
596
                }
597
            }
598
        }
599
600
        return $parsed;
601
    }
602
603
    /**
604
     * Finalize any processed files.
605
     *
606
     * @param  mixed $saved   One or more values, at time of saving.
607
     * @param  mixed $default The default value to return.
608
     * @return string|string[] One or more paths to the processed uploaded files.
609
     */
610
    protected function parseSavedValues($saved, $default = null)
611
    {
612
        $values = empty($saved) ? $default : $saved;
613
614
        if ($this['multiple']) {
615
            if (!is_array($values)) {
616
                $values = empty($values) && !is_numeric($values) ? [] : [ $values ];
617
            }
618
        } else {
619
            if (is_array($values)) {
620
                $values = reset($values);
621
            }
622
        }
623
624
        return $values;
625
    }
626
627
    /**
628
     * Upload to filesystem, from data URI.
629
     *
630
     * @param  mixed $data A data URI.
631
     * @throws Exception If data content decoding fails.
632
     * @throws InvalidArgumentException If the $data is invalid.
633
     * @return string|null The file path to the uploaded data.
634
     */
635
    public function dataUpload($data)
636
    {
637
        $filename = null;
638
        $content  = false;
639
640
        if (is_array($data)) {
641
            if (!isset($data['id'], $data['name'])) {
642
                throw new InvalidArgumentException(
643
                    '$data as an array MUST contain each of the keys "id" and "name", '.
644
                    'with each represented as a scalar value; one or more were missing or non-array values'
645
                );
646
            }
647
            // retrieve tmp file from temp dir
648
            $tmpDir  = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
649
            $tmpFile = $tmpDir.$data['id'];
650
            if (!file_exists($tmpFile)) {
651
                throw new Exception(sprintf(
652
                    'File %s does not exists',
653
                    $data['id']
654
                ));
655
            }
656
657
            $content  = file_get_contents($tmpFile);
658
            $filename = empty($data['name']) ? null : $data['name'];
659
660
            // delete tmp file
661
            unlink($tmpFile);
662
        } elseif (is_string($data)) {
663
            $content = file_get_contents($data);
664
        }
665
666
        if ($content === false) {
667
            throw new Exception(
668
                'File content could not be decoded'
669
            );
670
        }
671
672
        $info = new finfo(FILEINFO_MIME_TYPE);
673
        $this->setMimetype($info->buffer($content));
674
        $this->setFilesize(strlen($content));
675
        if (!$this->validateAcceptedMimetypes() || !$this->validateMaxFilesize()) {
676
            return null;
677
        }
678
679
        $targetPath = $this->uploadTarget($filename);
680
681
        $result = file_put_contents($targetPath, $content);
682
        if ($result === false) {
683
            $this->logger->warning(sprintf(
684
                'Failed to write file to %s',
685
                $targetPath
686
            ));
687
            return null;
688
        }
689
690
        $basePath  = $this->basePath();
691
        $targetPath = str_replace($basePath, '', $targetPath);
692
693
        return $targetPath;
694
    }
695
696
    /**
697
     * Upload to filesystem.
698
     *
699
     * @link https://github.com/slimphp/Slim/blob/3.12.1/Slim/Http/UploadedFile.php
700
     *     Adapted from slim/slim.
701
     *
702
     * @param  array $file A single $_FILES entry.
703
     * @throws InvalidArgumentException If the $file is invalid.
704
     * @return string|null The file path to the uploaded file.
705
     */
706
    public function fileUpload(array $file)
707
    {
708
        if (!isset($file['tmp_name'], $file['name'], $file['size'], $file['error'])) {
709
            throw new InvalidArgumentException(
710
                '$file MUST contain each of the keys "tmp_name", "name", "size", and "error", '.
711
                'with each represented as a scalar value; one or more were missing or non-array values'
712
            );
713
        }
714
715
        if ($file['error'] !== UPLOAD_ERR_OK) {
716
            $this->logger->warning(sprintf(
717
                'Upload error on file %s: %s',
718
                $file['name'],
719
                self::ERROR_MESSAGES[$this->error]
0 ignored issues
show
Bug introduced by
The property error does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
720
            ));
721
722
            return null;
723
        }
724
725
        if (file_exists($file['tmp_name'])) {
726
            $info = new finfo(FILEINFO_MIME_TYPE);
727
            $this->setMimetype($info->file($file['tmp_name']));
728
            $this->setFilesize(filesize($file['tmp_name']));
729
            if (!$this->validateAcceptedMimetypes() || !$this->validateMaxFilesize()) {
730
                return null;
731
            }
732
        } else {
733
            $this->logger->warning(sprintf(
734
                'File %s does not exists',
735
                $file['tmp_name']
736
            ));
737
            return null;
738
        }
739
740
        $targetPath = $this->uploadTarget($file['name']);
741
742
        if (!is_uploaded_file($file['tmp_name'])) {
743
            $this->logger->warning(sprintf(
744
                '%s is not a valid uploaded file',
745
                $file['tmp_name']
746
            ));
747
            return null;
748
        }
749
750
        if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
751
            $this->logger->warning(sprintf(
752
                'Error moving uploaded file %s to %s',
753
                $file['tmp_name'],
754
                $targetPath
755
            ));
756
            return null;
757
        }
758
759
        $this->logger->notice(sprintf(
760
            'File %s uploaded succesfully',
761
            $targetPath
762
        ));
763
764
        $basePath   = $this->basePath();
765
        $targetPath = str_replace($basePath, '', $targetPath);
766
767
        return $targetPath;
768
    }
769
770
    /**
771
     * @param string $filename Optional. The filename to save. If unset, a default filename will be generated.
772
     * @throws Exception If the target path is not writeable.
773
     * @return string
774
     */
775
    public function uploadTarget($filename = null)
776
    {
777
        $uploadPath = $this->basePath().$this['uploadPath'];
778
779
        if (!file_exists($uploadPath)) {
780
            // @todo: Feedback
781
            $this->logger->debug(
782
                'Path does not exist. Attempting to create path '.$uploadPath.'.',
783
                [ get_called_class().'::'.__FUNCTION__ ]
784
            );
785
            mkdir($uploadPath, 0777, true);
786
        }
787
788
        if (!is_writable($uploadPath)) {
789
            throw new Exception(
790
                'Error: upload directory is not writeable'
791
            );
792
        }
793
794
        $filename   = empty($filename) ? $this->generateFilename() : $this->sanitizeFilename($filename);
795
        $targetPath = $uploadPath.$filename;
796
797
        if ($this->fileExists($targetPath)) {
798
            if ($this['overwrite'] === true) {
799
                return $targetPath;
800
            } else {
801
                $targetPath = $uploadPath.$this->generateUniqueFilename($filename);
802
                while ($this->fileExists($targetPath)) {
803
                    $targetPath = $uploadPath.$this->generateUniqueFilename($filename);
804
                }
805
            }
806
        }
807
808
        return $targetPath;
809
    }
810
811
    /**
812
     * Checks whether a file or directory exists.
813
     *
814
     * PHP built-in's `file_exists` is only case-insensitive on case-insensitive filesystem (such as Windows)
815
     * This method allows to have the same validation across different platforms / filesystem.
816
     *
817
     * @param  string  $file            The full file to check.
818
     * @param  boolean $caseInsensitive Case-insensitive by default.
819
     * @return boolean
820
     */
821
    public function fileExists($file, $caseInsensitive = true)
822
    {
823
        if (!$this->isAbsolutePath($file)) {
824
            $file = $this->basePath().$file;
825
        }
826
827
        if (file_exists($file)) {
828
            return true;
829
        }
830
831
        if ($caseInsensitive === false) {
832
            return false;
833
        }
834
835
        $files = glob(dirname($file).DIRECTORY_SEPARATOR.'*', GLOB_NOSORT);
836
        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...
837
            $pattern = preg_quote($file, '#');
838
            foreach ($files as $f) {
839
                if (preg_match("#{$pattern}#i", $f)) {
840
                    return true;
841
                }
842
            }
843
        }
844
845
        return false;
846
    }
847
848
    /**
849
     * Sanitize a filename by removing characters from a blacklist and escaping dot.
850
     *
851
     * @param string $filename The filename to sanitize.
852
     * @return string The sanitized filename.
853
     */
854
    public function sanitizeFilename($filename)
855
    {
856
        // Remove blacklisted caharacters
857
        $blacklist = [ '/', '\\', '\0', '*', ':', '?', '"', '<', '>', '|', '#', '&', '!', '`', ' ' ];
858
        $filename = str_replace($blacklist, '_', $filename);
859
860
        // Avoid hidden file
861
        $filename = ltrim($filename, '.');
862
863
        return $filename;
864
    }
865
866
    /**
867
     * Render the given file to the given pattern.
868
     *
869
     * This method does not rename the given path.
870
     *
871
     * @uses   strtr() To replace tokens in the form `{{foobar}}`.
872
     * @param  string         $from The string being rendered.
873
     * @param  string         $to   The pattern replacing $from.
874
     * @param  array|callable $args Extra rename tokens.
875
     * @throws InvalidArgumentException If the given arguments are invalid.
876
     * @throws UnexpectedValueException If the renaming failed.
877
     * @return string Returns the rendered target.
878
     */
879
    public function renderFileRenamePattern($from, $to, $args = null)
880
    {
881
        if (!is_string($from)) {
882
            throw new InvalidArgumentException(sprintf(
883
                'The target to rename must be a string, received %s',
884
                (is_object($from) ? get_class($from) : gettype($from))
885
            ));
886
        }
887
888
        if (!is_string($to)) {
889
            throw new InvalidArgumentException(sprintf(
890
                'The rename pattern must be a string, received %s',
891
                (is_object($to) ? get_class($to) : gettype($to))
892
            ));
893
        }
894
895
        $info = pathinfo($from);
896
        $args = $this->renamePatternArgs($info, $args);
897
898
        $to = strtr($to, $args);
899
        if (strpos($to, '{{') !== false) {
900
            preg_match_all('~\{\{\s*(.*?)\s*\}\}~i', $to, $matches);
901
902
            throw new UnexpectedValueException(sprintf(
903
                'The rename pattern failed. Leftover tokens found: %s',
904
                implode(', ', $matches[1])
905
            ));
906
        }
907
908
        $to = str_replace($info['basename'], $to, $from);
909
910
        return $to;
911
    }
912
913
    /**
914
     * Generate a new filename from the property.
915
     *
916
     * @return string
917
     */
918
    public function generateFilename()
919
    {
920
        $filename  = $this['label'].' '.date('Y-m-d H-i-s');
921
        $extension = $this->generateExtension();
922
923
        if ($extension) {
924
            return $filename.'.'.$extension;
925
        } else {
926
            return $filename;
927
        }
928
    }
929
930
    /**
931
     * Generate a unique filename.
932
     *
933
     * @param  string|array $filename The filename to alter.
934
     * @throws InvalidArgumentException If the given filename is invalid.
935
     * @return string
936
     */
937
    public function generateUniqueFilename($filename)
938
    {
939
        if (!is_string($filename) && !is_array($filename)) {
940
            throw new InvalidArgumentException(sprintf(
941
                'The target must be a string or an array from [pathfino()], received %s',
942
                (is_object($filename) ? get_class($filename) : gettype($filename))
943
            ));
944
        }
945
946
        if (is_string($filename)) {
947
            $info = pathinfo($filename);
948
        } else {
949
            $info = $filename;
950
        }
951
952
        $filename = $info['filename'].'-'.uniqid();
953
954
        if (isset($info['extension']) && $info['extension']) {
955
            $filename .= '.'.$info['extension'];
956
        }
957
958
        return $filename;
959
    }
960
961
    /**
962
     * Generate the file extension from the property's value.
963
     *
964
     * @param  string $file The file to parse.
965
     * @return string The extension based on the MIME type.
966
     */
967
    public function generateExtension($file = null)
968
    {
969
        if ($file === null) {
970
            $file = $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...
971
        }
972
973
        // PHP 7.2
974
        if (is_string($file) && defined('FILEINFO_EXTENSION')) {
975
            $info = new finfo(FILEINFO_EXTENSION);
976
            $ext  = $info->file($file);
977
978
            if ($ext === '???') {
979
                return '';
980
            }
981
982
            if (strpos($ext, '/') !== false) {
983
                $ext = explode('/', $ext);
984
                $ext = reset($ext);
985
            }
986
987
            return $ext;
988
        }
989
990
        return '';
991
    }
992
993
    /**
994
     * @return string
995
     */
996
    public function getFilesystem()
997
    {
998
        return $this->filesystem;
999
    }
1000
1001
    /**
1002
     * @param string $filesystem The file system.
1003
     * @return self
1004
     */
1005
    public function setFilesystem($filesystem)
1006
    {
1007
        $this->filesystem = $filesystem;
1008
1009
        return $this;
1010
    }
1011
1012
    /**
1013
     * Inject dependencies from a DI Container.
1014
     *
1015
     * @param  Container $container A dependencies container instance.
1016
     * @return void
1017
     */
1018
    protected function setDependencies(Container $container)
1019
    {
1020
        parent::setDependencies($container);
1021
1022
        $this->basePath = $container['config']['base_path'];
1023
        $this->publicPath = $container['config']['public_path'];
1024
    }
1025
    /**
1026
     * Retrieve the path to the storage directory.
1027
     *
1028
     * @return string
1029
     */
1030
    protected function basePath()
1031
    {
1032
        if ($this['publicAccess']) {
1033
            return $this->publicPath;
1034
        } else {
1035
            return $this->basePath;
1036
        }
1037
    }
1038
1039
    /**
1040
     * Converts a php.ini notation for size to an integer.
1041
     *
1042
     * @param  mixed $size A php.ini notation for size.
1043
     * @throws InvalidArgumentException If the given parameter is invalid.
1044
     * @return integer Returns the size in bytes.
1045
     */
1046
    protected function parseIniSize($size)
1047
    {
1048
        if (is_numeric($size)) {
1049
            return $size;
1050
        }
1051
1052
        if (!is_string($size)) {
1053
            throw new InvalidArgumentException(
1054
                'Size must be an integer (in bytes, e.g.: 1024) or a string (e.g.: 1M).'
1055
            );
1056
        }
1057
1058
        $quant = 'bkmgtpezy';
1059
        $unit = preg_replace('/[^'.$quant.']/i', '', $size);
1060
        $size = preg_replace('/[^0-9\.]/', '', $size);
1061
1062
        if ($unit) {
1063
            $size = ($size * pow(1024, stripos($quant, $unit[0])));
1064
        }
1065
1066
        return round($size);
1067
    }
1068
1069
    /**
1070
     * Determine if the given file path is am absolute path.
1071
     *
1072
     * Note: Adapted from symfony\filesystem.
1073
     *
1074
     * @see https://github.com/symfony/symfony/blob/v3.2.2/LICENSE
1075
     *
1076
     * @param  string $file A file path.
1077
     * @return boolean Returns TRUE if the given path is absolute. Otherwise, returns FALSE.
1078
     */
1079
    protected function isAbsolutePath($file)
1080
    {
1081
        return strspn($file, '/\\', 0, 1)
1082
            || (strlen($file) > 3
1083
                && ctype_alpha($file[0])
1084
                && substr($file, 1, 1) === ':'
1085
                && strspn($file, '/\\', 2, 1))
1086
            || null !== parse_url($file, PHP_URL_SCHEME);
1087
    }
1088
1089
    /**
1090
     * Determine if the given value is a data URI.
1091
     *
1092
     * @param  mixed $val The value to check.
1093
     * @return boolean
1094
     */
1095
    protected function isDataUri($val)
1096
    {
1097
        return is_string($val) && preg_match('/^data:/i', $val);
1098
    }
1099
1100
    /**
1101
     * Determine if the given value is a data array.
1102
     *
1103
     * @param  mixed $val The value to check.
1104
     * @return boolean
1105
     */
1106
    protected function isDataArr($val)
1107
    {
1108
        return is_array($val) && isset($val['id']);
1109
    }
1110
1111
    /**
1112
     * Retrieve the rename pattern tokens for the given file.
1113
     *
1114
     * @param  string|array   $path The string to be parsed or an associative array of information about the file.
1115
     * @param  array|callable $args Extra rename tokens.
1116
     * @throws InvalidArgumentException If the given arguments are invalid.
1117
     * @throws UnexpectedValueException If the given path is invalid.
1118
     * @return string Returns the rendered target.
1119
     */
1120
    private function renamePatternArgs($path, $args = null)
1121
    {
1122
        if (!is_string($path) && !is_array($path)) {
1123
            throw new InvalidArgumentException(sprintf(
1124
                'The target must be a string or an array from [pathfino()], received %s',
1125
                (is_object($path) ? get_class($path) : gettype($path))
1126
            ));
1127
        }
1128
1129
        if (is_string($path)) {
1130
            $info = pathinfo($path);
1131
        } else {
1132
            $info = $path;
1133
        }
1134
1135
        if (!isset($info['basename']) || $info['basename'] === '') {
1136
            throw new UnexpectedValueException(
1137
                'The basename is missing from the target'
1138
            );
1139
        }
1140
1141
        if (!isset($info['filename']) || $info['filename'] === '') {
1142
            throw new UnexpectedValueException(
1143
                'The filename is missing from the target'
1144
            );
1145
        }
1146
1147
        if (!isset($info['extension'])) {
1148
            $info['extension'] = '';
1149
        }
1150
1151
        $defaults = [
1152
            '{{property}}'  => $this->ident(),
1153
            '{{label}}'     => $this['label'],
1154
            '{{extension}}' => $info['extension'],
1155
            '{{basename}}'  => $info['basename'],
1156
            '{{filename}}'  => $info['filename'],
1157
        ];
1158
1159
        if ($args === null) {
1160
            $args = $defaults;
1161
        } else {
1162
            if (is_callable($args)) {
1163
                /**
1164
                 * Rename Arguments Callback Routine
1165
                 *
1166
                 * @param  array             $info Information about the file path from {@see pathinfo()}.
1167
                 * @param  PropertyInterface $prop The related image property.
1168
                 * @return array
1169
                 */
1170
                $args = $args($info, $this);
1171
            }
1172
1173
            if (is_array($args)) {
1174
                $args = array_replace($defaults, $args);
1175
            } else {
1176
                throw new InvalidArgumentException(sprintf(
1177
                    'Arguments must be an array or a callable that returns an array, received %s',
1178
                    (is_object($args) ? get_class($args) : gettype($args))
1179
                ));
1180
            }
1181
        }
1182
1183
        return $args;
1184
    }
1185
1186
    /**
1187
     * Retrieve normalized file upload data for this property.
1188
     *
1189
     * @return array A tree of normalized $_FILE entries.
1190
     */
1191
    public function getUploadedFiles()
1192
    {
1193
        $propIdent = $this->ident();
1194
1195
        $filterErrNoFile = function (array $file) {
1196
            return $file['error'] !== UPLOAD_ERR_NO_FILE;
1197
        };
1198
        $uploadedFiles = static::parseUploadedFiles($_FILES, $filterErrNoFile, $propIdent);
1199
1200
        return $uploadedFiles;
1201
    }
1202
1203
    /**
1204
     * Parse a non-normalized, i.e. $_FILES superglobal, tree of uploaded file data.
1205
     *
1206
     * @link https://github.com/slimphp/Slim/blob/3.12.1/Slim/Http/UploadedFile.php
1207
     *     Adapted from slim/slim.
1208
     *
1209
     * @todo Add support for "dot" notation on $searchKey.
1210
     *
1211
     * @param  array    $uploadedFiles  The non-normalized tree of uploaded file data.
1212
     * @param  callable $filterCallback If specified, the callback function to used to filter files.
1213
     * @param  mixed    $searchKey      If specified, then only top-level keys containing these values are returned.
1214
     * @return array A tree of normalized $_FILE entries.
1215
     */
1216
    public static function parseUploadedFiles(array $uploadedFiles, callable $filterCallback = null, $searchKey = null)
1217
    {
1218
        if ($searchKey !== null) {
1219
            if (is_array($searchKey)) {
1220
                $uploadedFiles = array_intersect_key($uploadedFiles, array_flip($searchKey));
1221
                return static::parseUploadedFiles($uploadedFiles, $filterCallback);
1222
            }
1223
1224
            if (isset($uploadedFiles[$searchKey])) {
1225
                $uploadedFiles = [
1226
                    $searchKey => $uploadedFiles[$searchKey],
1227
                ];
1228
                $parsedFiles = static::parseUploadedFiles($uploadedFiles, $filterCallback);
1229
                if (isset($parsedFiles[$searchKey])) {
1230
                    return $parsedFiles[$searchKey];
1231
                }
1232
            }
1233
1234
            return [];
1235
        }
1236
1237
        $parsedFiles = [];
1238
        foreach ($uploadedFiles as $field => $uploadedFile) {
1239
            if (!isset($uploadedFile['error'])) {
1240
                if (is_array($uploadedFile)) {
1241
                    $subArray = static::parseUploadedFiles($uploadedFile, $filterCallback);
1242 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...
1243
                        if (!isset($parsedFiles[$field])) {
1244
                            $parsedFiles[$field] = [];
1245
                        }
1246
1247
                        $parsedFiles[$field] = $subArray;
1248
                    }
1249
                }
1250
                continue;
1251
            }
1252
1253
            if (!is_array($uploadedFile['error'])) {
1254
                if ($filterCallback === null || $filterCallback($uploadedFile, $field) === true) {
1255
                    if (!isset($parsedFiles[$field])) {
1256
                        $parsedFiles[$field] = [];
1257
                    }
1258
1259
                    $parsedFiles[$field] = [
1260
                        'tmp_name' => $uploadedFile['tmp_name'],
1261
                        'name'     => isset($uploadedFile['name']) ? $uploadedFile['name'] : null,
1262
                        'type'     => isset($uploadedFile['type']) ? $uploadedFile['type'] : null,
1263
                        'size'     => isset($uploadedFile['size']) ? $uploadedFile['size'] : null,
1264
                        'error'    => $uploadedFile['error'],
1265
                    ];
1266
                }
1267
            } else {
1268
                $subArray = [];
1269
                foreach ($uploadedFile['error'] as $fileIdx => $error) {
1270
                    // normalise subarray and re-parse to move the input's keyname up a level
1271
                    $subArray[$fileIdx] = [
1272
                        'tmp_name' => $uploadedFile['tmp_name'][$fileIdx],
1273
                        'name'     => $uploadedFile['name'][$fileIdx],
1274
                        'type'     => $uploadedFile['type'][$fileIdx],
1275
                        'size'     => $uploadedFile['size'][$fileIdx],
1276
                        'error'    => $uploadedFile['error'][$fileIdx],
1277
                    ];
1278
1279
                    $subArray = static::parseUploadedFiles($subArray, $filterCallback);
1280 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...
1281
                        if (!isset($parsedFiles[$field])) {
1282
                            $parsedFiles[$field] = [];
1283
                        }
1284
1285
                        $parsedFiles[$field] = $subArray;
1286
                    }
1287
                }
1288
            }
1289
        }
1290
1291
        return $parsedFiles;
1292
    }
1293
1294
    /**
1295
     * Normalize a file path string so that it can be checked safely.
1296
     *
1297
     * Attempt to avoid invalid encoding bugs by transcoding the path. Then
1298
     * remove any unnecessary path components including '.', '..' and ''.
1299
     *
1300
     * @link https://gist.github.com/thsutton/772287
1301
     *
1302
     * @param  string $path     The path to normalise.
1303
     * @param  string $encoding The name of the path iconv() encoding.
1304
     * @return string The path, normalised.
1305
     */
1306
    public static function normalizePath($path, $encoding = 'UTF-8')
1307
    {
1308
        $key = $path;
1309
1310
        if (isset(static::$normalizePathCache[$key])) {
1311
            return static::$normalizePathCache[$key];
1312
        }
1313
1314
        // Attempt to avoid path encoding problems.
1315
        $path = iconv($encoding, $encoding.'//IGNORE//TRANSLIT', $path);
1316
1317
        if (strpos($path, '..') !== false || strpos($path, './') !== false) {
1318
            // Process the components
1319
            $parts = explode('/', $path);
1320
            $safe = [];
1321
            foreach ($parts as $idx => $part) {
1322
                if ((empty($part) && !is_numeric($part)) || ($part === '.')) {
1323
                    continue;
1324
                } elseif ($part === '..') {
1325
                    array_pop($safe);
1326
                    continue;
1327
                } else {
1328
                    $safe[] = $part;
1329
                }
1330
            }
1331
1332
            // Return the "clean" path
1333
            $path = implode(DIRECTORY_SEPARATOR, $safe);
1334
1335
            if ($key[0] === '/' && $path[0] !== '/') {
1336
                $path = '/'.$path;
1337
            }
1338
        }
1339
1340
        static::$normalizePathCache[$key] = $path;
1341
1342
        return static::$normalizePathCache[$key];
1343
    }
1344
}
1345