Test Setup Failed
Push — master ( 6592af...c06444 )
by Chauncey
08:19
created

FileProperty::getMimetypeFor()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 1
nc 1
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
     * The filesystem to use while uploading a file.
106
     *
107
     * @var string
108
     */
109
    private $filesystem = self::DEFAULT_FILESYSTEM;
110
111
    /**
112
     * @return string
113
     */
114
    public function type()
115
    {
116
        return 'file';
117
    }
118
119
    /**
120
     * Set whether uploaded files should be publicly available.
121
     *
122
     * @param  boolean $public Whether uploaded files should be accessible (TRUE) or not (FALSE) from the web root.
123
     * @return self
124
     */
125
    public function setPublicAccess($public)
126
    {
127
        $this->publicAccess = !!$public;
128
129
        return $this;
130
    }
131
132
    /**
133
     * Determine if uploaded files should be publicly available.
134
     *
135
     * @return boolean
136
     */
137
    public function getPublicAccess()
138
    {
139
        return $this->publicAccess;
140
    }
141
142
    /**
143
     * Set the destination (directory) where uploaded files are stored.
144
     *
145
     * The path must be relative to the {@see self::basePath()},
146
     *
147
     * @param  string $path The destination directory, relative to project's root.
148
     * @throws InvalidArgumentException If the path is not a string.
149
     * @return self
150
     */
151
    public function setUploadPath($path)
152
    {
153
        if (!is_string($path)) {
154
            throw new InvalidArgumentException(
155
                'Upload path must be a string'
156
            );
157
        }
158
159
        // Sanitize upload path (force trailing slash)
160
        $this->uploadPath = rtrim($path, '/').'/';
161
162
        return $this;
163
    }
164
165
    /**
166
     * Retrieve the destination for the uploaded file(s).
167
     *
168
     * @return string
169
     */
170
    public function getUploadPath()
171
    {
172
        return $this->uploadPath;
173
    }
174
175
    /**
176
     * Set whether existing destinations should be overwritten.
177
     *
178
     * @param  boolean $overwrite Whether existing destinations should be overwritten (TRUE) or not (FALSE).
179
     * @return self
180
     */
181
    public function setOverwrite($overwrite)
182
    {
183
        $this->overwrite = !!$overwrite;
184
185
        return $this;
186
    }
187
188
    /**
189
     * Determine if existing destinations should be overwritten.
190
     *
191
     * @return boolean
192
     */
193
    public function getOverwrite()
194
    {
195
        return $this->overwrite;
196
    }
197
198
    /**
199
     * @param  string[] $mimetypes The accepted mimetypes.
200
     * @return self
201
     */
202
    public function setAcceptedMimetypes(array $mimetypes)
203
    {
204
        $this->acceptedMimetypes = $mimetypes;
205
206
        return $this;
207
    }
208
209
    /**
210
     * @return string[]
211
     */
212
    public function getAcceptedMimetypes()
213
    {
214
        return $this->acceptedMimetypes;
215
    }
216
217
    /**
218
     * Set the MIME type.
219
     *
220
     * @param  mixed $type The file MIME type.
221
     * @throws InvalidArgumentException If the MIME type argument is not a string.
222
     * @return FileProperty Chainable
223
     */
224
    public function setMimetype($type)
225
    {
226
        if ($type === null || $type === false) {
227
            $this->mimetype = null;
228
229
            return $this;
230
        }
231
232
        if (!is_string($type)) {
233
            throw new InvalidArgumentException(
234
                'Mimetype must be a string'
235
            );
236
        }
237
238
        $this->mimetype = $type;
239
240
        return $this;
241
    }
242
243
    /**
244
     * Retrieve the MIME type.
245
     *
246
     * @return string
247
     */
248
    public function getMimetype()
249
    {
250
        if (!$this->mimetype) {
251
            $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...
252
253
            if (!$val) {
254
                return '';
255
            }
256
257
            $this->setMimetype($this->getMimetypeFor(strval($val)));
258
        }
259
260
        return $this->mimetype;
261
    }
262
263
    /**
264
     * Alias of {@see self::getMimetype()}.
265
     *
266
     * @return string
267
     */
268
    public function mimetype()
269
    {
270
        return $this->getMimetype();
271
    }
272
273
    /**
274
     * Extract the MIME type from the given file.
275
     *
276
     * @uses   finfo
277
     * @param  string $file The file to check.
278
     * @return string|false Returns the given file's MIME type or FALSE if an error occurred.
279
     */
280
    public function getMimetypeFor($file)
281
    {
282
        $info = new finfo(FILEINFO_MIME_TYPE);
283
284
        return $info->file($file);
285
    }
286
287
    /**
288
     * Alias of {@see self::getMimetypeFor()}.
289
     *
290
     * @param  string $file The file to check.
291
     * @return string|false
292
     */
293
    public function mimetypeFor($file)
294
    {
295
        return $this->getMimetypeFor($file);
296
    }
297
298
    /**
299
     * Set the maximium size accepted for an uploaded files.
300
     *
301
     * @param  string|integer $size The maximum file size allowed, in bytes.
302
     * @throws InvalidArgumentException If the size argument is not an integer.
303
     * @return FileProperty Chainable
304
     */
305
    public function setMaxFilesize($size)
306
    {
307
        $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...
308
309
        return $this;
310
    }
311
312
    /**
313
     * Retrieve the maximum size accepted for uploaded files.
314
     *
315
     * If null or 0, then no limit. Defaults to 128 MB.
316
     *
317
     * @return integer
318
     */
319
    public function getMaxFilesize()
320
    {
321
        if (!isset($this->maxFilesize)) {
322
            return $this->maxFilesizeAllowedByPhp();
323
        }
324
325
        return $this->maxFilesize;
326
    }
327
328
    /**
329
     * Retrieve the maximum size (in bytes) allowed for an uploaded file
330
     * as configured in {@link http://php.net/manual/en/ini.php `php.ini`}.
331
     *
332
     * @param string|null $iniDirective If $iniDirective is provided, then it is filled with
333
     *     the name of the PHP INI directive corresponding to the maximum size allowed.
334
     * @return integer
335
     */
336
    public function maxFilesizeAllowedByPhp(&$iniDirective = null)
337
    {
338
        $postMaxSize = $this->parseIniSize(ini_get('post_max_size'));
339
        $uploadMaxFilesize = $this->parseIniSize(ini_get('upload_max_filesize'));
340
341
        if ($postMaxSize < $uploadMaxFilesize) {
342
            $iniDirective = 'post_max_size';
343
344
            return $postMaxSize;
345
        } else {
346
            $iniDirective = 'upload_max_filesize';
347
348
            return $uploadMaxFilesize;
349
        }
350
    }
351
352
    /**
353
     * @param  integer $size The file size, in bytes.
354
     * @throws InvalidArgumentException If the size argument is not an integer.
355
     * @return FileProperty Chainable
356
     */
357
    public function setFilesize($size)
358
    {
359
        if (!is_int($size)) {
360
            throw new InvalidArgumentException(
361
                'Filesize must be an integer, in bytes.'
362
            );
363
        }
364
        $this->filesize = $size;
365
366
        return $this;
367
    }
368
369
    /**
370
     * @return integer
371
     */
372
    public function getFilesize()
373
    {
374
        if (!$this->filesize) {
375
            $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...
376
            if (!$val || !file_exists($val) || !is_readable($val)) {
377
                return 0;
378
            } else {
379
                $this->filesize = filesize($val);
380
            }
381
        }
382
383
        return $this->filesize;
384
    }
385
386
    /**
387
     * Alias of {@see self::getFilesize()}.
388
     *
389
     * @return integer
390
     */
391
    public function filesize()
392
    {
393
        return $this->getFilesize();
394
    }
395
396
    /**
397
     * @return array
398
     */
399
    public function validationMethods()
400
    {
401
        $parentMethods = parent::validationMethods();
402
403
        return array_merge($parentMethods, [
404
            'acceptedMimetypes',
405
            'maxFilesize',
406
        ]);
407
    }
408
409
    /**
410
     * @return boolean
411
     */
412
    public function validateAcceptedMimetypes()
413
    {
414
        $acceptedMimetypes = $this['acceptedMimetypes'];
415
        if (empty($acceptedMimetypes)) {
416
            // No validation rules = always true
417
            return true;
418
        }
419
420
        if ($this->mimetype) {
421
            $mimetype = $this->mimetype;
422
        } else {
423
            $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...
424
            if (!$val) {
425
                return true;
426
            }
427
            $mimetype = $this->getMimetypeFor($val);
428
        }
429
        $valid = false;
430
        foreach ($acceptedMimetypes as $m) {
431
            if ($m == $mimetype) {
432
                $valid = true;
433
                break;
434
            }
435
        }
436
        if (!$valid) {
437
            $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...
438
        }
439
440
        return $valid;
441
    }
442
443
    /**
444
     * @return boolean
445
     */
446
    public function validateMaxFilesize()
447
    {
448
        $maxFilesize = $this['maxFilesize'];
449
        if ($maxFilesize == 0) {
450
            // No max size rule = always true
451
            return true;
452
        }
453
454
        $filesize = $this->filesize();
455
        $valid = ($filesize <= $maxFilesize);
456
        if (!$valid) {
457
            $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...
458
        }
459
460
        return $valid;
461
    }
462
463
    /**
464
     * Get the SQL type (Storage format)
465
     *
466
     * Stored as `VARCHAR` for max_length under 255 and `TEXT` for other, longer strings
467
     *
468
     * @see StorablePropertyTrait::sqlType()
469
     * @return string The SQL type
470
     */
471
    public function sqlType()
472
    {
473
        // Multiple strings are always stored as TEXT because they can hold multiple values
474
        if ($this['multiple']) {
475
            return 'TEXT';
476
        } else {
477
            return 'VARCHAR(255)';
478
        }
479
    }
480
481
    /**
482
     * @see StorablePropertyTrait::sqlPdoType()
483
     * @return integer
484
     */
485
    public function sqlPdoType()
486
    {
487
        return PDO::PARAM_STR;
488
    }
489
490
    /**
491
     * Process file uploads {@see AbstractProperty::save() parsing values}.
492
     *
493
     * @param  mixed $val The value, at time of saving.
494
     * @return mixed
495
     */
496
    public function save($val)
497
    {
498
        if ($val instanceof Translation) {
499
            $values = $val->data();
500
        } else {
501
            $values = $val;
502
        }
503
504
        $uploadedFiles = $this->getUploadedFiles();
505
506
        if ($this['l10n']) {
507
            foreach ($this->translator()->availableLocales() as $lang) {
508
                if (!isset($values[$lang])) {
509
                    $values[$lang] = $this['multiple'] ? [] : '';
510
                }
511
512
                $parsedFiles = [];
513
514
                if (isset($uploadedFiles[$lang])) {
515
                    $parsedFiles = $this->saveFileUploads($uploadedFiles[$lang]);
516
                }
517
518
                if (empty($parsedFiles)) {
519
                    $parsedFiles = $this->saveDataUploads($values[$lang]);
520
                }
521
522
                $values[$lang] = $this->parseSavedValues($parsedFiles, $values[$lang]);
523
            }
524
        } else {
525
            $parsedFiles = [];
526
527
            if (!empty($uploadedFiles)) {
528
                $parsedFiles = $this->saveFileUploads($uploadedFiles);
529
            }
530
531
            if (empty($parsedFiles)) {
532
                $parsedFiles = $this->saveDataUploads($values);
533
            }
534
535
            $values = $this->parseSavedValues($parsedFiles, $values);
536
        }
537
538
        return $values;
539
    }
540
541
    /**
542
     * Process and transfer any data URIs to the filesystem,
543
     * and carry over any pre-processed file paths.
544
     *
545
     * @param  mixed $values One or more data URIs, data entries, or processed file paths.
546
     * @return string|string[] One or more paths to the processed uploaded files.
547
     */
548
    protected function saveDataUploads($values)
549
    {
550
        // Bag value if singular
551
        if (!is_array($values) || isset($values['id'])) {
552
            $values = [ $values ];
553
        }
554
555
        $parsed = [];
556
        foreach ($values as $value) {
557
            if ($this->isDataArr($value) || $this->isDataUri($value)) {
558
                $path = $this->dataUpload($value);
559
                if ($path !== null) {
560
                    $parsed[] = $path;
561
                }
562
            } elseif (is_string($value) && !empty($value)) {
563
                $parsed[] = $value;
564
            }
565
        }
566
567
        return $parsed;
568
    }
569
570
    /**
571
     * Process and transfer any uploaded files to the filesystem.
572
     *
573
     * @param  mixed $files One or more normalized $_FILE entries.
574
     * @return string[] One or more paths to the processed uploaded files.
575
     */
576
    protected function saveFileUploads($files)
577
    {
578
        // Bag value if singular
579
        if (isset($files['error'])) {
580
            $files = [ $files ];
581
        }
582
583
        $parsed = [];
584
        foreach ($files as $file) {
585
            if (isset($file['error'])) {
586
                $path = $this->fileUpload($file);
587
                if ($path !== null) {
588
                    $parsed[] = $path;
589
                }
590
            }
591
        }
592
593
        return $parsed;
594
    }
595
596
    /**
597
     * Finalize any processed files.
598
     *
599
     * @param  mixed $saved   One or more values, at time of saving.
600
     * @param  mixed $default The default value to return.
601
     * @return string|string[] One or more paths to the processed uploaded files.
602
     */
603
    protected function parseSavedValues($saved, $default = null)
604
    {
605
        $values = empty($saved) ? $default : $saved;
606
607
        if ($this['multiple']) {
608
            if (!is_array($values)) {
609
                $values = empty($values) && !is_numeric($values) ? [] : [ $values ];
610
            }
611
        } else {
612
            if (is_array($values)) {
613
                $values = reset($values);
614
            }
615
        }
616
617
        return $values;
618
    }
619
620
    /**
621
     * Upload to filesystem, from data URI.
622
     *
623
     * @param  mixed $data A data URI.
624
     * @throws Exception If data content decoding fails.
625
     * @throws InvalidArgumentException If the $data is invalid.
626
     * @return string|null The file path to the uploaded data.
627
     */
628
    public function dataUpload($data)
629
    {
630
        $filename = null;
631
        $content  = false;
632
633
        if (is_array($data)) {
634
            if (!isset($data['id'], $data['name'])) {
635
                throw new InvalidArgumentException(
636
                    '$data as an array MUST contain each of the keys "id" and "name", '.
637
                    'with each represented as a scalar value; one or more were missing or non-array values'
638
                );
639
            }
640
            // retrieve tmp file from temp dir
641
            $tmpDir  = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
642
            $tmpFile = $tmpDir.$data['id'];
643
            if (!file_exists($tmpFile)) {
644
                throw new Exception(sprintf(
645
                    'File %s does not exists',
646
                    $data['id']
647
                ));
648
            }
649
650
            $content  = file_get_contents($tmpFile);
651
            $filename = empty($data['name']) ? null : $data['name'];
652
653
            // delete tmp file
654
            unlink($tmpFile);
655
        } elseif (is_string($data)) {
656
            $content = file_get_contents($data);
657
        }
658
659
        if ($content === false) {
660
            throw new Exception(
661
                'File content could not be decoded'
662
            );
663
        }
664
665
        $info = new finfo(FILEINFO_MIME_TYPE);
666
        $this->setMimetype($info->buffer($content));
667
        $this->setFilesize(strlen($content));
668
        if (!$this->validateAcceptedMimetypes() || !$this->validateMaxFilesize()) {
669
            return null;
670
        }
671
672
        $targetPath = $this->uploadTarget($filename);
673
674
        $result = file_put_contents($targetPath, $content);
675
        if ($result === false) {
676
            $this->logger->warning(sprintf(
677
                'Failed to write file to %s',
678
                $targetPath
679
            ));
680
            return null;
681
        }
682
683
        $basePath  = $this->basePath();
684
        $targetPath = str_replace($basePath, '', $targetPath);
685
686
        return $targetPath;
687
    }
688
689
    /**
690
     * Upload to filesystem.
691
     *
692
     * @link https://github.com/slimphp/Slim/blob/3.12.1/Slim/Http/UploadedFile.php
693
     *     Adapted from slim/slim.
694
     *
695
     * @param  array $file A single $_FILES entry.
696
     * @throws InvalidArgumentException If the $file is invalid.
697
     * @return string|null The file path to the uploaded file.
698
     */
699
    public function fileUpload(array $file)
700
    {
701
        if (!isset($file['tmp_name'], $file['name'], $file['size'], $file['error'])) {
702
            throw new InvalidArgumentException(
703
                '$file MUST contain each of the keys "tmp_name", "name", "size", and "error", '.
704
                'with each represented as a scalar value; one or more were missing or non-array values'
705
            );
706
        }
707
708
        if ($file['error'] !== UPLOAD_ERR_OK) {
709
            $this->logger->warning(sprintf(
710
                'Upload error on file %s: %s',
711
                $file['name'],
712
                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...
713
            ));
714
715
            return null;
716
        }
717
718
        if (file_exists($file['tmp_name'])) {
719
            $info = new finfo(FILEINFO_MIME_TYPE);
720
            $this->setMimetype($info->file($file['tmp_name']));
721
            $this->setFilesize(filesize($file['tmp_name']));
722
            if (!$this->validateAcceptedMimetypes() || !$this->validateMaxFilesize()) {
723
                return null;
724
            }
725
        } else {
726
            $this->logger->warning(sprintf(
727
                'File %s does not exists',
728
                $file['tmp_name']
729
            ));
730
            return null;
731
        }
732
733
        $targetPath = $this->uploadTarget($file['name']);
734
735
        if (!is_uploaded_file($file['tmp_name'])) {
736
            $this->logger->warning(sprintf(
737
                '%s is not a valid uploaded file',
738
                $file['tmp_name']
739
            ));
740
            return null;
741
        }
742
743
        if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
744
            $this->logger->warning(sprintf(
745
                'Error moving uploaded file %s to %s',
746
                $file['tmp_name'],
747
                $targetPath
748
            ));
749
            return null;
750
        }
751
752
        $this->logger->notice(sprintf(
753
            'File %s uploaded succesfully',
754
            $targetPath
755
        ));
756
757
        $basePath   = $this->basePath();
758
        $targetPath = str_replace($basePath, '', $targetPath);
759
760
        return $targetPath;
761
    }
762
763
    /**
764
     * @param string $filename Optional. The filename to save. If unset, a default filename will be generated.
765
     * @throws Exception If the target path is not writeable.
766
     * @return string
767
     */
768
    public function uploadTarget($filename = null)
769
    {
770
        $uploadPath = $this->basePath().$this['uploadPath'];
771
772
        if (!file_exists($uploadPath)) {
773
            // @todo: Feedback
774
            $this->logger->debug(
775
                'Path does not exist. Attempting to create path '.$uploadPath.'.',
776
                [ get_called_class().'::'.__FUNCTION__ ]
777
            );
778
            mkdir($uploadPath, 0777, true);
779
        }
780
781
        if (!is_writable($uploadPath)) {
782
            throw new Exception(
783
                'Error: upload directory is not writeable'
784
            );
785
        }
786
787
        $filename   = empty($filename) ? $this->generateFilename() : $this->sanitizeFilename($filename);
788
        $targetPath = $uploadPath.$filename;
789
790
        if ($this->fileExists($targetPath)) {
791
            if ($this['overwrite'] === true) {
792
                return $targetPath;
793
            } else {
794
                $targetPath = $uploadPath.$this->generateUniqueFilename($filename);
795
                while ($this->fileExists($targetPath)) {
796
                    $targetPath = $uploadPath.$this->generateUniqueFilename($filename);
797
                }
798
            }
799
        }
800
801
        return $targetPath;
802
    }
803
804
    /**
805
     * Checks whether a file or directory exists.
806
     *
807
     * PHP built-in's `file_exists` is only case-insensitive on case-insensitive filesystem (such as Windows)
808
     * This method allows to have the same validation across different platforms / filesystem.
809
     *
810
     * @param  string  $file            The full file to check.
811
     * @param  boolean $caseInsensitive Case-insensitive by default.
812
     * @return boolean
813
     */
814
    public function fileExists($file, $caseInsensitive = true)
815
    {
816
        if (!$this->isAbsolutePath($file)) {
817
            $file = $this->basePath().$file;
818
        }
819
820
        if (file_exists($file)) {
821
            return true;
822
        }
823
824
        if ($caseInsensitive === false) {
825
            return false;
826
        }
827
828
        $files = glob(dirname($file).DIRECTORY_SEPARATOR.'*', GLOB_NOSORT);
829
        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...
830
            $pattern = preg_quote($file, '#');
831
            foreach ($files as $f) {
832
                if (preg_match("#{$pattern}#i", $f)) {
833
                    return true;
834
                }
835
            }
836
        }
837
838
        return false;
839
    }
840
841
    /**
842
     * Sanitize a filename by removing characters from a blacklist and escaping dot.
843
     *
844
     * @param string $filename The filename to sanitize.
845
     * @return string The sanitized filename.
846
     */
847
    public function sanitizeFilename($filename)
848
    {
849
        // Remove blacklisted caharacters
850
        $blacklist = [ '/', '\\', '\0', '*', ':', '?', '"', '<', '>', '|', '#', '&', '!', '`', ' ' ];
851
        $filename = str_replace($blacklist, '_', $filename);
852
853
        // Avoid hidden file
854
        $filename = ltrim($filename, '.');
855
856
        return $filename;
857
    }
858
859
    /**
860
     * Render the given file to the given pattern.
861
     *
862
     * This method does not rename the given path.
863
     *
864
     * @uses   strtr() To replace tokens in the form `{{foobar}}`.
865
     * @param  string         $from The string being rendered.
866
     * @param  string         $to   The pattern replacing $from.
867
     * @param  array|callable $args Extra rename tokens.
868
     * @throws InvalidArgumentException If the given arguments are invalid.
869
     * @throws UnexpectedValueException If the renaming failed.
870
     * @return string Returns the rendered target.
871
     */
872
    public function renderFileRenamePattern($from, $to, $args = null)
873
    {
874
        if (!is_string($from)) {
875
            throw new InvalidArgumentException(sprintf(
876
                'The target to rename must be a string, received %s',
877
                (is_object($from) ? get_class($from) : gettype($from))
878
            ));
879
        }
880
881
        if (!is_string($to)) {
882
            throw new InvalidArgumentException(sprintf(
883
                'The rename pattern must be a string, received %s',
884
                (is_object($to) ? get_class($to) : gettype($to))
885
            ));
886
        }
887
888
        $info = pathinfo($from);
889
        $args = $this->renamePatternArgs($info, $args);
890
891
        $to = strtr($to, $args);
892
        if (strpos($to, '{{') !== false) {
893
            preg_match_all('~\{\{\s*(.*?)\s*\}\}~i', $to, $matches);
894
895
            throw new UnexpectedValueException(sprintf(
896
                'The rename pattern failed. Leftover tokens found: %s',
897
                implode(', ', $matches[1])
898
            ));
899
        }
900
901
        $to = str_replace($info['basename'], $to, $from);
902
903
        return $to;
904
    }
905
906
    /**
907
     * Generate a new filename from the property.
908
     *
909
     * @return string
910
     */
911
    public function generateFilename()
912
    {
913
        $filename  = $this['label'].' '.date('Y-m-d H-i-s');
914
        $extension = $this->generateExtension();
915
916
        if ($extension) {
917
            return $filename.'.'.$extension;
918
        } else {
919
            return $filename;
920
        }
921
    }
922
923
    /**
924
     * Generate a unique filename.
925
     *
926
     * @param  string|array $filename The filename to alter.
927
     * @throws InvalidArgumentException If the given filename is invalid.
928
     * @return string
929
     */
930
    public function generateUniqueFilename($filename)
931
    {
932
        if (!is_string($filename) && !is_array($filename)) {
933
            throw new InvalidArgumentException(sprintf(
934
                'The target must be a string or an array from [pathfino()], received %s',
935
                (is_object($filename) ? get_class($filename) : gettype($filename))
936
            ));
937
        }
938
939
        if (is_string($filename)) {
940
            $info = pathinfo($filename);
941
        } else {
942
            $info = $filename;
943
        }
944
945
        $filename = $info['filename'].'-'.uniqid();
946
947
        if (isset($info['extension']) && $info['extension']) {
948
            $filename .= '.'.$info['extension'];
949
        }
950
951
        return $filename;
952
    }
953
954
    /**
955
     * Generate the file extension from the property's value.
956
     *
957
     * @param  string $file The file to parse.
958
     * @return string The extension based on the MIME type.
959
     */
960
    public function generateExtension($file = null)
961
    {
962
        if ($file === null) {
963
            $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...
964
        }
965
966
        // PHP 7.2
967
        if (is_string($file) && defined('FILEINFO_EXTENSION')) {
968
            $info = new finfo(FILEINFO_EXTENSION);
969
            $ext  = $info->file($file);
970
971
            if ($ext === '???') {
972
                return '';
973
            }
974
975
            if (strpos($ext, '/') !== false) {
976
                $ext = explode('/', $ext);
977
                $ext = reset($ext);
978
            }
979
980
            return $ext;
981
        }
982
983
        return '';
984
    }
985
986
    /**
987
     * @return string
988
     */
989
    public function getFilesystem()
990
    {
991
        return $this->filesystem;
992
    }
993
994
    /**
995
     * @param string $filesystem The file system.
996
     * @return self
997
     */
998
    public function setFilesystem($filesystem)
999
    {
1000
        $this->filesystem = $filesystem;
1001
1002
        return $this;
1003
    }
1004
1005
    /**
1006
     * Inject dependencies from a DI Container.
1007
     *
1008
     * @param  Container $container A dependencies container instance.
1009
     * @return void
1010
     */
1011
    protected function setDependencies(Container $container)
1012
    {
1013
        parent::setDependencies($container);
1014
1015
        $this->basePath = $container['config']['base_path'];
1016
        $this->publicPath = $container['config']['public_path'];
1017
    }
1018
    /**
1019
     * Retrieve the path to the storage directory.
1020
     *
1021
     * @return string
1022
     */
1023
    protected function basePath()
1024
    {
1025
        if ($this['publicAccess']) {
1026
            return $this->publicPath;
1027
        } else {
1028
            return $this->basePath;
1029
        }
1030
    }
1031
1032
    /**
1033
     * Converts a php.ini notation for size to an integer.
1034
     *
1035
     * @param  mixed $size A php.ini notation for size.
1036
     * @throws InvalidArgumentException If the given parameter is invalid.
1037
     * @return integer Returns the size in bytes.
1038
     */
1039
    protected function parseIniSize($size)
1040
    {
1041
        if (is_numeric($size)) {
1042
            return $size;
1043
        }
1044
1045
        if (!is_string($size)) {
1046
            throw new InvalidArgumentException(
1047
                'Size must be an integer (in bytes, e.g.: 1024) or a string (e.g.: 1M).'
1048
            );
1049
        }
1050
1051
        $quant = 'bkmgtpezy';
1052
        $unit = preg_replace('/[^'.$quant.']/i', '', $size);
1053
        $size = preg_replace('/[^0-9\.]/', '', $size);
1054
1055
        if ($unit) {
1056
            $size = ($size * pow(1024, stripos($quant, $unit[0])));
1057
        }
1058
1059
        return round($size);
1060
    }
1061
1062
    /**
1063
     * Determine if the given file path is am absolute path.
1064
     *
1065
     * Note: Adapted from symfony\filesystem.
1066
     *
1067
     * @see https://github.com/symfony/symfony/blob/v3.2.2/LICENSE
1068
     *
1069
     * @param  string $file A file path.
1070
     * @return boolean Returns TRUE if the given path is absolute. Otherwise, returns FALSE.
1071
     */
1072
    protected function isAbsolutePath($file)
1073
    {
1074
        return strspn($file, '/\\', 0, 1)
1075
            || (strlen($file) > 3
1076
                && ctype_alpha($file[0])
1077
                && substr($file, 1, 1) === ':'
1078
                && strspn($file, '/\\', 2, 1))
1079
            || null !== parse_url($file, PHP_URL_SCHEME);
1080
    }
1081
1082
    /**
1083
     * Determine if the given value is a data URI.
1084
     *
1085
     * @param  mixed $val The value to check.
1086
     * @return boolean
1087
     */
1088
    protected function isDataUri($val)
1089
    {
1090
        return is_string($val) && preg_match('/^data:/i', $val);
1091
    }
1092
1093
    /**
1094
     * Determine if the given value is a data array.
1095
     *
1096
     * @param  mixed $val The value to check.
1097
     * @return boolean
1098
     */
1099
    protected function isDataArr($val)
1100
    {
1101
        return is_array($val) && isset($val['id']);
1102
    }
1103
1104
    /**
1105
     * Retrieve the rename pattern tokens for the given file.
1106
     *
1107
     * @param  string|array   $path The string to be parsed or an associative array of information about the file.
1108
     * @param  array|callable $args Extra rename tokens.
1109
     * @throws InvalidArgumentException If the given arguments are invalid.
1110
     * @throws UnexpectedValueException If the given path is invalid.
1111
     * @return string Returns the rendered target.
1112
     */
1113
    private function renamePatternArgs($path, $args = null)
1114
    {
1115
        if (!is_string($path) && !is_array($path)) {
1116
            throw new InvalidArgumentException(sprintf(
1117
                'The target must be a string or an array from [pathfino()], received %s',
1118
                (is_object($path) ? get_class($path) : gettype($path))
1119
            ));
1120
        }
1121
1122
        if (is_string($path)) {
1123
            $info = pathinfo($path);
1124
        } else {
1125
            $info = $path;
1126
        }
1127
1128
        if (!isset($info['basename']) || $info['basename'] === '') {
1129
            throw new UnexpectedValueException(
1130
                'The basename is missing from the target'
1131
            );
1132
        }
1133
1134
        if (!isset($info['filename']) || $info['filename'] === '') {
1135
            throw new UnexpectedValueException(
1136
                'The filename is missing from the target'
1137
            );
1138
        }
1139
1140
        $defaults = [
1141
            '{{property}}'  => $this->ident(),
1142
            '{{label}}'     => $this['label'],
1143
            '{{extension}}' => $info['extension'],
1144
            '{{basename}}'  => $info['basename'],
1145
            '{{filename}}'  => $info['filename'],
1146
        ];
1147
1148
        if ($args === null) {
1149
            $args = $defaults;
1150
        } else {
1151
            if (is_callable($args)) {
1152
                /**
1153
                 * Rename Arguments Callback Routine
1154
                 *
1155
                 * @param  array             $info Information about the file path from {@see pathinfo()}.
1156
                 * @param  PropertyInterface $prop The related image property.
1157
                 * @return array
1158
                 */
1159
                $args = $args($info, $this);
1160
            }
1161
1162
            if (is_array($args)) {
1163
                $args = array_replace($defaults, $args);
1164
            } else {
1165
                throw new InvalidArgumentException(sprintf(
1166
                    'Arguments must be an array or a callable that returns an array, received %s',
1167
                    (is_object($args) ? get_class($args) : gettype($args))
1168
                ));
1169
            }
1170
        }
1171
1172
        return $args;
1173
    }
1174
1175
    /**
1176
     * Retrieve normalized file upload data for this property.
1177
     *
1178
     * @return array A tree of normalized $_FILE entries.
1179
     */
1180
    public function getUploadedFiles()
1181
    {
1182
        $propIdent = $this->ident();
1183
1184
        $filterErrNoFile = function (array $file) {
1185
            return $file['error'] !== UPLOAD_ERR_NO_FILE;
1186
        };
1187
        $uploadedFiles = static::parseUploadedFiles($_FILES, $filterErrNoFile, $propIdent);
1188
1189
        return $uploadedFiles;
1190
    }
1191
1192
    /**
1193
     * Parse a non-normalized, i.e. $_FILES superglobal, tree of uploaded file data.
1194
     *
1195
     * @link https://github.com/slimphp/Slim/blob/3.12.1/Slim/Http/UploadedFile.php
1196
     *     Adapted from slim/slim.
1197
     *
1198
     * @todo Add support for "dot" notation on $searchKey.
1199
     *
1200
     * @param  array    $uploadedFiles  The non-normalized tree of uploaded file data.
1201
     * @param  callable $filterCallback If specified, the callback function to used to filter files.
1202
     * @param  mixed    $searchKey      If specified, then only top-level keys containing these values are returned.
1203
     * @return array A tree of normalized $_FILE entries.
1204
     */
1205
    public static function parseUploadedFiles(array $uploadedFiles, callable $filterCallback = null, $searchKey = null)
1206
    {
1207
        if ($searchKey !== null) {
1208
            if (is_array($searchKey)) {
1209
                $uploadedFiles = array_intersect_key($uploadedFiles, array_flip($searchKey));
1210
                return static::parseUploadedFiles($uploadedFiles, $filterCallback);
1211
            }
1212
1213
            if (isset($uploadedFiles[$searchKey])) {
1214
                $uploadedFiles = [
1215
                    $searchKey => $uploadedFiles[$searchKey],
1216
                ];
1217
                $parsedFiles = static::parseUploadedFiles($uploadedFiles, $filterCallback);
1218
                if (isset($parsedFiles[$searchKey])) {
1219
                    return $parsedFiles[$searchKey];
1220
                }
1221
            }
1222
1223
            return [];
1224
        }
1225
1226
        $parsedFiles = [];
1227
        foreach ($uploadedFiles as $field => $uploadedFile) {
1228
            if (!isset($uploadedFile['error'])) {
1229
                if (is_array($uploadedFile)) {
1230
                    $subArray = static::parseUploadedFiles($uploadedFile, $filterCallback);
1231 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...
1232
                        if (!isset($parsedFiles[$field])) {
1233
                            $parsedFiles[$field] = [];
1234
                        }
1235
1236
                        $parsedFiles[$field] = $subArray;
1237
                    }
1238
                }
1239
                continue;
1240
            }
1241
1242
            if (!is_array($uploadedFile['error'])) {
1243
                if ($filterCallback === null || $filterCallback($uploadedFile, $field) === true) {
1244
                    if (!isset($parsedFiles[$field])) {
1245
                        $parsedFiles[$field] = [];
1246
                    }
1247
1248
                    $parsedFiles[$field] = [
1249
                        'tmp_name' => $uploadedFile['tmp_name'],
1250
                        'name'     => isset($uploadedFile['name']) ? $uploadedFile['name'] : null,
1251
                        'type'     => isset($uploadedFile['type']) ? $uploadedFile['type'] : null,
1252
                        'size'     => isset($uploadedFile['size']) ? $uploadedFile['size'] : null,
1253
                        'error'    => $uploadedFile['error'],
1254
                    ];
1255
                }
1256
            } else {
1257
                $subArray = [];
1258
                foreach ($uploadedFile['error'] as $fileIdx => $error) {
1259
                    // normalise subarray and re-parse to move the input's keyname up a level
1260
                    $subArray[$fileIdx] = [
1261
                        'tmp_name' => $uploadedFile['tmp_name'][$fileIdx],
1262
                        'name'     => $uploadedFile['name'][$fileIdx],
1263
                        'type'     => $uploadedFile['type'][$fileIdx],
1264
                        'size'     => $uploadedFile['size'][$fileIdx],
1265
                        'error'    => $uploadedFile['error'][$fileIdx],
1266
                    ];
1267
1268
                    $subArray = static::parseUploadedFiles($subArray, $filterCallback);
1269 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...
1270
                        if (!isset($parsedFiles[$field])) {
1271
                            $parsedFiles[$field] = [];
1272
                        }
1273
1274
                        $parsedFiles[$field] = $subArray;
1275
                    }
1276
                }
1277
            }
1278
        }
1279
1280
        return $parsedFiles;
1281
    }
1282
}
1283