Passed
Push — master ( 003da0...69fb1d )
by William
07:21
created

libraries/classes/File.php (3 issues)

1
<?php
2
/* vim: set expandtab sw=4 ts=4 sts=4: */
3
/**
4
 * file upload functions
5
 *
6
 * @package PhpMyAdmin
7
 */
8
declare(strict_types=1);
9
10
namespace PhpMyAdmin;
11
12
use PhpMyAdmin\Core;
13
use PhpMyAdmin\Message;
14
use PhpMyAdmin\Util;
15
use PhpMyAdmin\ZipExtension;
16
17
/**
18
 * File wrapper class
19
 *
20
 * @todo when uploading a file into a blob field, should we also consider using
21
 *       chunks like in import? UPDATE `table` SET `field` = `field` + [chunk]
22
 *
23
 * @package PhpMyAdmin
24
 */
25
class File
26
{
27
    /**
28
     * @var string the temporary file name
29
     * @access protected
30
     */
31
    protected $_name = null;
32
33
    /**
34
     * @var string the content
35
     * @access protected
36
     */
37
    protected $_content = null;
38
39
    /**
40
     * @var Message|null the error message
41
     * @access protected
42
     */
43
    protected $_error_message = null;
44
45
    /**
46
     * @var bool whether the file is temporary or not
47
     * @access protected
48
     */
49
    protected $_is_temp = false;
50
51
    /**
52
     * @var string type of compression
53
     * @access protected
54
     */
55
    protected $_compression = null;
56
57
    /**
58
     * @var integer
59
     */
60
    protected $_offset = 0;
61
62
    /**
63
     * @var integer size of chunk to read with every step
64
     */
65
    protected $_chunk_size = 32768;
66
67
    /**
68
     * @var resource file handle
69
     */
70
    protected $_handle = null;
71
72
    /**
73
     * @var boolean whether to decompress content before returning
74
     */
75
    protected $_decompress = false;
76
77
    /**
78
     * @var string charset of file
79
     */
80
    protected $_charset = null;
81
82
    /**
83
     * @var ZipExtension
84
     */
85
    private $zipExtension;
86
87
    /**
88
     * constructor
89
     *
90
     * @param boolean|string $name file name or false
91
     *
92
     * @access public
93
     */
94
    public function __construct($name = false)
95
    {
96
        if ($name && is_string($name)) {
97
            $this->setName($name);
98
        }
99
100
        if (extension_loaded('zip')) {
101
            $this->zipExtension = new ZipExtension();
102
        }
103
    }
104
105
    /**
106
     * destructor
107
     *
108
     * @see     File::cleanUp()
109
     * @access  public
110
     */
111
    public function __destruct()
112
    {
113
        $this->cleanUp();
114
    }
115
116
    /**
117
     * deletes file if it is temporary, usually from a moved upload file
118
     *
119
     * @access  public
120
     * @return boolean success
121
     */
122
    public function cleanUp(): bool
123
    {
124
        if ($this->isTemp()) {
125
            return $this->delete();
126
        }
127
128
        return true;
129
    }
130
131
    /**
132
     * deletes the file
133
     *
134
     * @access  public
135
     * @return boolean success
136
     */
137
    public function delete(): bool
138
    {
139
        return unlink($this->getName());
140
    }
141
142
    /**
143
     * checks or sets the temp flag for this file
144
     * file objects with temp flags are deleted with object destruction
145
     *
146
     * @param boolean $is_temp sets the temp flag
147
     *
148
     * @return boolean File::$_is_temp
149
     * @access  public
150
     */
151
    public function isTemp(?bool $is_temp = null): bool
152
    {
153
        if (null !== $is_temp) {
154
            $this->_is_temp = $is_temp;
155
        }
156
157
        return $this->_is_temp;
158
    }
159
160
    /**
161
     * accessor
162
     *
163
     * @param string|null $name file name
164
     *
165
     * @return void
166
     * @access  public
167
     */
168
    public function setName(?string $name): void
169
    {
170
        $this->_name = trim($name);
171
    }
172
173
    /**
174
     * Gets file content
175
     *
176
     * @return string|false the binary file content,
177
     *                      or false if no content
178
     *
179
     * @access  public
180
     */
181
    public function getRawContent()
182
    {
183
        if (null === $this->_content) {
184
            if ($this->isUploaded() && ! $this->checkUploadedFile()) {
185
                return false;
186
            }
187
188
            if (! $this->isReadable()) {
189
                return false;
190
            }
191
192
            if (function_exists('file_get_contents')) {
193
                $this->_content = file_get_contents($this->getName());
194
            } elseif ($size = filesize($this->getName())) {
195
                $handle = fopen($this->getName(), 'rb');
196
                $this->_content = fread($handle, $size);
197
                fclose($handle);
198
            }
199
        }
200
201
        return $this->_content;
202
    }
203
204
    /**
205
     * Gets file content
206
     *
207
     * @return string|false the binary file content as a string,
208
     *                      or false if no content
209
     *
210
     * @access  public
211
     */
212
    public function getContent()
213
    {
214
        $result = $this->getRawContent();
215
        if ($result === false) {
216
            return false;
217
        }
218
        return '0x' . bin2hex($result);
219
    }
220
221
    /**
222
     * Whether file is uploaded.
223
     *
224
     * @access  public
225
     *
226
     * @return bool
227
     */
228
    public function isUploaded(): bool
229
    {
230
        return is_uploaded_file($this->getName());
231
    }
232
233
    /**
234
     * accessor
235
     *
236
     * @access public
237
     * @return string|null File::$_name
238
     */
239
    public function getName(): ?string
240
    {
241
        return $this->_name;
242
    }
243
244
    /**
245
     * Initializes object from uploaded file.
246
     *
247
     * @param string $name name of file uploaded
248
     *
249
     * @return boolean success
250
     * @access  public
251
     */
252
    public function setUploadedFile(string $name): bool
253
    {
254
        $this->setName($name);
255
256
        if (! $this->isUploaded()) {
257
            $this->setName(null);
258
            $this->_error_message = Message::error(__('File was not an uploaded file.'));
259
            return false;
260
        }
261
262
        return true;
263
    }
264
265
    /**
266
     * Loads uploaded file from table change request.
267
     *
268
     * @param string $key       the md5 hash of the column name
269
     * @param string $rownumber number of row to process
270
     *
271
     * @return boolean success
272
     * @access  public
273
     */
274
    public function setUploadedFromTblChangeRequest(
275
        string $key,
276
        string $rownumber
277
    ): bool {
278
        if (! isset($_FILES['fields_upload'])
279
            || empty($_FILES['fields_upload']['name']['multi_edit'][$rownumber][$key])
280
        ) {
281
            return false;
282
        }
283
        $file = File::fetchUploadedFromTblChangeRequestMultiple(
284
            $_FILES['fields_upload'],
285
            $rownumber,
286
            $key
287
        );
288
289
        // check for file upload errors
290
        switch ($file['error']) {
291
        // we do not use the PHP constants here cause not all constants
292
        // are defined in all versions of PHP - but the correct constants names
293
        // are given as comment
294
            case 0: //UPLOAD_ERR_OK:
295
                return $this->setUploadedFile($file['tmp_name']);
296
            case 4: //UPLOAD_ERR_NO_FILE:
297
                break;
298
            case 1: //UPLOAD_ERR_INI_SIZE:
299
                $this->_error_message = Message::error(__(
300
                    'The uploaded file exceeds the upload_max_filesize directive in '
301
                    . 'php.ini.'
302
                ));
303
                break;
304
            case 2: //UPLOAD_ERR_FORM_SIZE:
305
                $this->_error_message = Message::error(__(
306
                    'The uploaded file exceeds the MAX_FILE_SIZE directive that was '
307
                    . 'specified in the HTML form.'
308
                ));
309
                break;
310
            case 3: //UPLOAD_ERR_PARTIAL:
311
                $this->_error_message = Message::error(__(
312
                    'The uploaded file was only partially uploaded.'
313
                ));
314
                break;
315
            case 6: //UPLOAD_ERR_NO_TMP_DIR:
316
                $this->_error_message = Message::error(__('Missing a temporary folder.'));
317
                break;
318
            case 7: //UPLOAD_ERR_CANT_WRITE:
319
                $this->_error_message = Message::error(__('Failed to write file to disk.'));
320
                break;
321
            case 8: //UPLOAD_ERR_EXTENSION:
322
                $this->_error_message = Message::error(__('File upload stopped by extension.'));
323
                break;
324
            default:
325
                $this->_error_message = Message::error(__('Unknown error in file upload.'));
326
        } // end switch
327
328
        return false;
329
    }
330
331
    /**
332
     * strips some dimension from the multi-dimensional array from $_FILES
333
     *
334
     * <code>
335
     * $file['name']['multi_edit'][$rownumber][$key] = [value]
336
     * $file['type']['multi_edit'][$rownumber][$key] = [value]
337
     * $file['size']['multi_edit'][$rownumber][$key] = [value]
338
     * $file['tmp_name']['multi_edit'][$rownumber][$key] = [value]
339
     * $file['error']['multi_edit'][$rownumber][$key] = [value]
340
     *
341
     * // becomes:
342
     *
343
     * $file['name'] = [value]
344
     * $file['type'] = [value]
345
     * $file['size'] = [value]
346
     * $file['tmp_name'] = [value]
347
     * $file['error'] = [value]
348
     * </code>
349
     *
350
     * @param array  $file      the array
351
     * @param string $rownumber number of row to process
352
     * @param string $key       key to process
353
     *
354
     * @return array
355
     * @access  public
356
     * @static
357
     */
358
    public function fetchUploadedFromTblChangeRequestMultiple(
359
        array $file,
360
        string $rownumber,
361
        string $key
362
    ): array {
363
        $new_file = [
364
            'name' => $file['name']['multi_edit'][$rownumber][$key],
365
            'type' => $file['type']['multi_edit'][$rownumber][$key],
366
            'size' => $file['size']['multi_edit'][$rownumber][$key],
367
            'tmp_name' => $file['tmp_name']['multi_edit'][$rownumber][$key],
368
            'error' => $file['error']['multi_edit'][$rownumber][$key],
369
        ];
370
371
        return $new_file;
372
    }
373
374
    /**
375
     * sets the name if the file to the one selected in the tbl_change form
376
     *
377
     * @param string $key       the md5 hash of the column name
378
     * @param string $rownumber number of row to process
379
     *
380
     * @return boolean success
381
     * @access  public
382
     */
383
    public function setSelectedFromTblChangeRequest(
384
        string $key,
385
        ?string $rownumber = null
386
    ): bool {
387
        if (! empty($_REQUEST['fields_uploadlocal']['multi_edit'][$rownumber][$key])
388
            && is_string($_REQUEST['fields_uploadlocal']['multi_edit'][$rownumber][$key])
389
        ) {
390
            // ... whether with multiple rows ...
391
            return $this->setLocalSelectedFile(
392
                $_REQUEST['fields_uploadlocal']['multi_edit'][$rownumber][$key]
393
            );
394
        }
395
396
        return false;
397
    }
398
399
    /**
400
     * Returns possible error message.
401
     *
402
     * @access  public
403
     * @return Message|null error message
404
     */
405
    public function getError(): ?Message
406
    {
407
        return $this->_error_message;
408
    }
409
410
    /**
411
     * Checks whether there was any error.
412
     *
413
     * @access  public
414
     * @return boolean whether an error occurred or not
415
     */
416
    public function isError(): bool
417
    {
418
        return ! is_null($this->_error_message);
419
    }
420
421
    /**
422
     * checks the superglobals provided if the tbl_change form is submitted
423
     * and uses the submitted/selected file
424
     *
425
     * @param string $key       the md5 hash of the column name
426
     * @param string $rownumber number of row to process
427
     *
428
     * @return boolean success
429
     * @access  public
430
     */
431
    public function checkTblChangeForm(string $key, string $rownumber): bool
432
    {
433
        if ($this->setUploadedFromTblChangeRequest($key, $rownumber)) {
434
            // well done ...
435
            $this->_error_message = null;
436
            return true;
437
        } elseif ($this->setSelectedFromTblChangeRequest($key, $rownumber)) {
438
            // well done ...
439
            $this->_error_message = null;
440
            return true;
441
        }
442
        // all failed, whether just no file uploaded/selected or an error
443
444
        return false;
445
    }
446
447
    /**
448
     * Sets named file to be read from UploadDir.
449
     *
450
     * @param string $name file name
451
     *
452
     * @return boolean success
453
     * @access  public
454
     */
455
    public function setLocalSelectedFile(string $name): bool
456
    {
457
        if (empty($GLOBALS['cfg']['UploadDir'])) {
458
            return false;
459
        }
460
461
        $this->setName(
462
            Util::userDir($GLOBALS['cfg']['UploadDir']) . Core::securePath($name)
463
        );
464
        if (@is_link($this->getName())) {
465
            $this->_error_message = __('File is a symbolic link');
466
            $this->setName(null);
467
            return false;
468
        }
469
        if (! $this->isReadable()) {
470
            $this->_error_message = Message::error(__('File could not be read!'));
471
            $this->setName(null);
472
            return false;
473
        }
474
475
        return true;
476
    }
477
478
    /**
479
     * Checks whether file can be read.
480
     *
481
     * @access  public
482
     * @return boolean whether the file is readable or not
483
     */
484
    public function isReadable(): bool
485
    {
486
        // suppress warnings from being displayed, but not from being logged
487
        // any file access outside of open_basedir will issue a warning
488
        return @is_readable((string) $this->getName());
489
    }
490
491
    /**
492
     * If we are on a server with open_basedir, we must move the file
493
     * before opening it. The FAQ 1.11 explains how to create the "./tmp"
494
     * directory - if needed
495
     *
496
     * @todo move check of $cfg['TempDir'] into Config?
497
     * @access  public
498
     * @return boolean whether uploaded file is fine or not
499
     */
500
    public function checkUploadedFile(): bool
501
    {
502
        if ($this->isReadable()) {
503
            return true;
504
        }
505
506
        $tmp_subdir = $GLOBALS['PMA_Config']->getUploadTempDir();
507
        if (is_null($tmp_subdir)) {
508
            // cannot create directory or access, point user to FAQ 1.11
509
            $this->_error_message = Message::error(__(
510
                'Error moving the uploaded file, see [doc@faq1-11]FAQ 1.11[/doc].'
511
            ));
512
            return false;
513
        }
514
515
        $new_file_to_upload = tempnam(
516
            $tmp_subdir,
517
            basename($this->getName())
518
        );
519
520
        // suppress warnings from being displayed, but not from being logged
521
        // any file access outside of open_basedir will issue a warning
522
        ob_start();
523
        $move_uploaded_file_result = move_uploaded_file(
524
            $this->getName(),
525
            $new_file_to_upload
526
        );
527
        ob_end_clean();
528
        if (! $move_uploaded_file_result) {
529
            $this->_error_message = Message::error(__('Error while moving uploaded file.'));
530
            return false;
531
        }
532
533
        $this->setName($new_file_to_upload);
534
        $this->isTemp(true);
535
536
        if (! $this->isReadable()) {
537
            $this->_error_message = Message::error(__('Cannot read uploaded file.'));
538
            return false;
539
        }
540
541
        return true;
542
    }
543
544
    /**
545
     * Detects what compression the file uses
546
     *
547
     * @todo    move file read part into readChunk() or getChunk()
548
     * @todo    add support for compression plugins
549
     * @access  protected
550
     * @return  string|false false on error, otherwise string MIME type of
551
     *                       compression, none for none
552
     */
553
    protected function detectCompression()
554
    {
555
        // suppress warnings from being displayed, but not from being logged
556
        // f.e. any file access outside of open_basedir will issue a warning
557
        ob_start();
558
        $file = fopen($this->getName(), 'rb');
559
        ob_end_clean();
560
561
        if (! $file) {
0 ignored issues
show
$file is of type false|resource, thus it always evaluated to false.
Loading history...
562
            $this->_error_message = Message::error(__('File could not be read!'));
563
            return false;
564
        }
565
566
        $this->_compression = Util::getCompressionMimeType($file);
567
        return $this->_compression;
568
    }
569
570
    /**
571
     * Sets whether the content should be decompressed before returned
572
     *
573
     * @param boolean $decompress whether to decompress
574
     *
575
     * @return void
576
     */
577
    public function setDecompressContent(bool $decompress): void
578
    {
579
        $this->_decompress = $decompress;
580
    }
581
582
    /**
583
     * Returns the file handle
584
     *
585
     * @return resource file handle
586
     */
587
    public function getHandle()
588
    {
589
        if (null === $this->_handle) {
590
            $this->open();
591
        }
592
        return $this->_handle;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->_handle could also return false which is incompatible with the documented return type resource. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
593
    }
594
595
    /**
596
     * Sets the file handle
597
     *
598
     * @param resource $handle file handle
599
     *
600
     * @return void
601
     */
602
    public function setHandle($handle): void
603
    {
604
        $this->_handle = $handle;
605
    }
606
607
608
    /**
609
     * Sets error message for unsupported compression.
610
     *
611
     * @return void
612
     */
613
    public function errorUnsupported(): void
614
    {
615
        $this->_error_message = Message::error(sprintf(
616
            __(
617
                'You attempted to load file with unsupported compression (%s). '
618
                . 'Either support for it is not implemented or disabled by your '
619
                . 'configuration.'
620
            ),
621
            $this->getCompression()
622
        ));
623
    }
624
625
    /**
626
     * Attempts to open the file.
627
     *
628
     * @return bool
629
     */
630
    public function open(): bool
631
    {
632
        if (! $this->_decompress) {
633
            $this->_handle = @fopen($this->getName(), 'r');
0 ignored issues
show
Documentation Bug introduced by
It seems like @fopen($this->getName(), 'r') can also be of type false. However, the property $_handle is declared as type resource. 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...
634
        }
635
636
        switch ($this->getCompression()) {
637
            case false:
638
                return false;
639
            case 'application/bzip2':
640
                if ($GLOBALS['cfg']['BZipDump'] && function_exists('bzopen')) {
641
                    $this->_handle = @bzopen($this->getName(), 'r');
642
                } else {
643
                    $this->errorUnsupported();
644
                    return false;
645
                }
646
                break;
647
            case 'application/gzip':
648
                if ($GLOBALS['cfg']['GZipDump'] && function_exists('gzopen')) {
649
                    $this->_handle = @gzopen($this->getName(), 'r');
650
                } else {
651
                    $this->errorUnsupported();
652
                    return false;
653
                }
654
                break;
655
            case 'application/zip':
656
                if ($GLOBALS['cfg']['ZipDump'] && function_exists('zip_open')) {
657
                    return $this->openZip();
658
                }
659
660
                $this->errorUnsupported();
661
                return false;
662
            case 'none':
663
                $this->_handle = @fopen($this->getName(), 'r');
664
                break;
665
            default:
666
                $this->errorUnsupported();
667
                return false;
668
        }
669
670
        return ($this->_handle !== false);
671
    }
672
673
    /**
674
     * Opens file from zip
675
     *
676
     * @param string|null $specific_entry Entry to open
677
     *
678
     * @return bool
679
     */
680
    public function openZip(?string $specific_entry = null): bool
681
    {
682
        $result = $this->zipExtension->getContents($this->getName(), $specific_entry);
683
        if (! empty($result['error'])) {
684
            $this->_error_message = Message::rawError($result['error']);
685
            return false;
686
        }
687
        $this->_content = $result['data'];
688
        $this->_offset = 0;
689
        return true;
690
    }
691
692
    /**
693
     * Checks whether we've reached end of file
694
     *
695
     * @return bool
696
     */
697
    public function eof(): bool
698
    {
699
        if (! is_null($this->_handle)) {
700
            return feof($this->_handle);
701
        }
702
        return $this->_offset == strlen($this->_content);
703
    }
704
705
    /**
706
     * Closes the file
707
     *
708
     * @return void
709
     */
710
    public function close(): void
711
    {
712
        if (! is_null($this->_handle)) {
713
            fclose($this->_handle);
714
            $this->_handle = null;
715
        } else {
716
            $this->_content = '';
717
            $this->_offset = 0;
718
        }
719
        $this->cleanUp();
720
    }
721
722
    /**
723
     * Reads data from file
724
     *
725
     * @param int $size Number of bytes to read
726
     *
727
     * @return string
728
     */
729
    public function read(int $size): string
730
    {
731
        switch ($this->_compression) {
732
            case 'application/bzip2':
733
                return bzread($this->_handle, $size);
734
            case 'application/gzip':
735
                return gzread($this->_handle, $size);
736
            case 'application/zip':
737
                $result = mb_strcut($this->_content, $this->_offset, $size);
738
                $this->_offset += strlen($result);
739
                return $result;
740
            case 'none':
741
            default:
742
                return fread($this->_handle, $size);
743
        }
744
    }
745
746
    /**
747
     * Returns the character set of the file
748
     *
749
     * @return string character set of the file
750
     */
751
    public function getCharset(): string
752
    {
753
        return $this->_charset;
754
    }
755
756
    /**
757
     * Sets the character set of the file
758
     *
759
     * @param string $charset character set of the file
760
     *
761
     * @return void
762
     */
763
    public function setCharset(string $charset): void
764
    {
765
        $this->_charset = $charset;
766
    }
767
768
    /**
769
     * Returns compression used by file.
770
     *
771
     * @return string MIME type of compression, none for none
772
     * @access  public
773
     */
774
    public function getCompression(): string
775
    {
776
        if (null === $this->_compression) {
777
            return $this->detectCompression();
778
        }
779
780
        return $this->_compression;
781
    }
782
783
    /**
784
     * Returns the offset
785
     *
786
     * @return integer the offset
787
     */
788
    public function getOffset(): int
789
    {
790
        return $this->_offset;
791
    }
792
793
    /**
794
     * Returns the chunk size
795
     *
796
     * @return integer the chunk size
797
     */
798
    public function getChunkSize(): int
799
    {
800
        return $this->_chunk_size;
801
    }
802
803
    /**
804
     * Sets the chunk size
805
     *
806
     * @param integer $chunk_size the chunk size
807
     *
808
     * @return void
809
     */
810
    public function setChunkSize(int $chunk_size): void
811
    {
812
        $this->_chunk_size = $chunk_size;
813
    }
814
815
    /**
816
     * Returns the length of the content in the file
817
     *
818
     * @return integer the length of the file content
819
     */
820
    public function getContentLength(): int
821
    {
822
        return strlen($this->_content);
823
    }
824
}
825