Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.92% covered (success)
92.92%
105 / 113
90.32% covered (success)
90.32%
28 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Upload
92.92% covered (success)
92.92%
105 / 113
90.32% covered (success)
90.32%
28 / 31
71.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkDuplicate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesFileExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDefaults
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 setUploadDir
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 setMaxSize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAllowedTypes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setDisallowedTypes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addAllowedType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addDisallowedType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeAllowedType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeDisallowedType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 overwrite
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUploadDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUploadedFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUploadedFullPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaxSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDisallowedTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAllowed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 isNotAllowed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 isOverwrite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSuccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fileExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkFilename
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 test
83.33% covered (success)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
12.67
 upload
77.78% covered (success)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
7.54
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http\Server;
16
17/**
18 * HTTP server upload class
19 *
20 * @category   Pop
21 * @package    Pop\Http
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    6.0.0
26 */
27class Upload
28{
29
30    /**
31     * File is too big by the user-defined max size
32     */
33    const UPLOAD_ERR_USER_SIZE = 9;
34
35    /**
36     * File is not allowed, per user-definition
37     */
38    const UPLOAD_ERR_NOT_ALLOWED = 10;
39
40    /**
41     * Upload directory does not exist
42     */
43    const UPLOAD_ERR_DIR_NOT_EXIST = 11;
44
45    /**
46     * Upload directory not writable
47     */
48    const UPLOAD_ERR_DIR_NOT_WRITABLE = 12;
49
50    /**
51     * File security error
52     */
53    const UPLOAD_ERR_FILE_NOT_SECURE = 13;
54
55    /**
56     * Unexpected error
57     */
58    const UPLOAD_ERR_UNEXPECTED = 14;
59
60    /**
61     * Error messageed
62     * @var array
63     */
64    protected static array $errorMessages = [
65         0 => 'The file uploaded successfully',
66         1 => 'The uploaded file exceeds the upload_max_filesize directive',
67         2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive in the HTML form',
68         3 => 'The uploaded file was only partially uploaded',
69         4 => 'No file was uploaded',
70         6 => 'Missing a temporary folder',
71         7 => 'Failed to write file to disk',
72         8 => 'A PHP extension stopped the file upload',
73         9 => 'The uploaded file exceeds the user-defined max file size',
74        10 => 'The uploaded file is not allowed',
75        11 => 'The specified upload directory does not exist',
76        12 => 'The specified upload directory is not writable',
77        13 => 'The uploaded file was not uploaded securely',
78        14 => 'Unexpected error'
79    ];
80
81    /**
82     * The upload directory path
83     * @var ?string
84     */
85    protected ?string $uploadDir = null;
86
87    /**
88     * The final filename of the uploaded file
89     * @var ?string
90     */
91    protected ?string $uploadedFile = null;
92
93    /**
94     * Allowed maximum file size
95     * @var int
96     */
97    protected int $maxSize = 0;
98
99    /**
100     * Allowed file types
101     * @var array
102     */
103    protected array $allowedTypes = [];
104
105    /**
106     * Disallowed file types
107     * @var array
108     */
109    protected array $disallowedTypes = [];
110
111    /**
112     * Overwrite flag
113     * @var bool
114     */
115    protected bool $overwrite = false;
116
117    /**
118     * Error flag
119     * @var int
120     */
121    protected int $error = 0;
122
123    /**
124     * Constructor
125     *
126     * Instantiate a file upload object
127     *
128     * @param  string $dir
129     * @param  int    $maxSize
130     * @param  ?array $disallowedTypes
131     * @param  ?array $allowedTypes
132     */
133    public function __construct(string $dir, int $maxSize = 0, ?array $disallowedTypes = null, ?array $allowedTypes = null)
134    {
135        $this->setUploadDir($dir);
136        $this->setMaxSize($maxSize);
137
138        if (($disallowedTypes !== null) && (count($disallowedTypes) > 0)) {
139            $this->setDisallowedTypes($disallowedTypes);
140        }
141        if (($allowedTypes !== null) && (count($allowedTypes) > 0)) {
142            $this->setAllowedTypes($allowedTypes);
143        }
144    }
145
146    /**
147     * Create an upload object
148     *
149     * @param  string $dir
150     * @param  int    $maxSize
151     * @param  ?array $disallowedTypes
152     * @param  ?array $allowedTypes
153     * @return Upload
154     */
155    public static function create(string $dir, int $maxSize = 0, ?array $disallowedTypes = null, ?array $allowedTypes = null): Upload
156    {
157        return new static($dir, $maxSize, $disallowedTypes, $allowedTypes);
158    }
159
160    /**
161     * Check for a duplicate filename in the upload directory, and return a modified filename if it exists already
162     *
163     * @param  string $dir
164     * @param  string $file
165     * @return string
166     */
167    public static function checkDuplicate(string $dir, string $file): string
168    {
169        return (new static($dir))->checkFilename($file);
170    }
171
172    /**
173     * Check if the file exists already in the upload directory
174     *
175     * @param  string $dir
176     * @param  string $file
177     * @return bool
178     */
179    public static function doesFileExists(string $dir, string $file): bool
180    {
181        return (new static($dir))->fileExists($file);
182    }
183
184    /**
185     * Set default file upload settings
186     *
187     * @return Upload
188     */
189    public function setDefaults(): Upload
190    {
191        // Allow basic text, graphic, audio/video, data and archive file types
192        $allowedTypes = [
193            'ai', 'aif', 'aiff', 'avi', 'bmp', 'bz2', 'csv', 'doc', 'docx', 'eps', 'fla', 'flv', 'gif', 'gz',
194            'jpe','jpg', 'jpeg', 'log', 'md', 'mov', 'mp2', 'mp3', 'mp4', 'mpg', 'mpeg', 'otf', 'pdf',
195            'png', 'ppt', 'pptx', 'psd', 'rar', 'svg', 'swf', 'tar', 'tbz', 'tbz2', 'tgz', 'tif', 'tiff', 'tsv',
196            'ttf', 'txt', 'wav', 'wma', 'wmv', 'xls', 'xlsx', 'xml', 'zip'
197        ];
198
199        // Disallow programming/development file types
200        $disallowedTypes = [
201            'css', 'htm', 'html', 'js', 'json', 'pgsql', 'php', 'php3', 'php4', 'php5', 'sql', 'sqlite', 'yaml', 'yml'
202        ];
203
204        // Set max file size to 10 MBs
205        $this->setMaxSize(10000000);
206        $this->setAllowedTypes($allowedTypes);
207        $this->setDisallowedTypes($disallowedTypes);
208
209        return $this;
210    }
211
212    /**
213     * Set the upload directory
214     *
215     * @param  string $dir
216     * @return Upload
217     */
218    public function setUploadDir(string $dir): Upload
219    {
220        // Check to see if the upload directory exists.
221        if (!file_exists($dir) || !is_dir($dir)) {
222            $this->error = self::UPLOAD_ERR_DIR_NOT_EXIST;
223        // Check to see if the permissions are set correctly.
224        } else if (!is_writable($dir)) {
225            $this->error = self::UPLOAD_ERR_DIR_NOT_WRITABLE;
226        }
227
228        $this->uploadDir = $dir;
229        return $this;
230    }
231
232    /**
233     * Set the upload directory
234     *
235     * @param  int $maxSize
236     * @return Upload
237     */
238    public function setMaxSize(int $maxSize): Upload
239    {
240        $this->maxSize = (int)$maxSize;
241        return $this;
242    }
243
244    /**
245     * Set the allowed types
246     *
247     * @param  array $allowedTypes
248     * @return Upload
249     */
250    public function setAllowedTypes(array $allowedTypes): Upload
251    {
252        foreach ($allowedTypes as $type) {
253            $this->addAllowedType($type);
254        }
255        return $this;
256    }
257
258    /**
259     * Set the disallowed types
260     *
261     * @param  array $disallowedTypes
262     * @return Upload
263     */
264    public function setDisallowedTypes(array $disallowedTypes): Upload
265    {
266        foreach ($disallowedTypes as $type) {
267            $this->addDisallowedType($type);
268        }
269        return $this;
270    }
271
272    /**
273     * Add an allowed type
274     *
275     * @param  string $type
276     * @return Upload
277     */
278    public function addAllowedType(string $type): Upload
279    {
280        if (!in_array(strtolower($type), $this->allowedTypes)) {
281            $this->allowedTypes[] = strtolower($type);
282        }
283        return $this;
284    }
285
286    /**
287     * Add a disallowed type
288     *
289     * @param  string $type
290     * @return Upload
291     */
292    public function addDisallowedType(string $type): Upload
293    {
294        if (!in_array(strtolower($type), $this->disallowedTypes)) {
295            $this->disallowedTypes[] = strtolower($type);
296        }
297        return $this;
298    }
299
300    /**
301     * Remove an allowed type
302     *
303     * @param  string $type
304     * @return Upload
305     */
306    public function removeAllowedType(string $type): Upload
307    {
308        if (in_array(strtolower($type), $this->allowedTypes)) {
309            unset($this->allowedTypes[array_search(strtolower($type), $this->allowedTypes)]);
310        }
311        return $this;
312    }
313
314    /**
315     * Remove a disallowed type
316     *
317     * @param  string $type
318     * @return Upload
319     */
320    public function removeDisallowedType(string $type): Upload
321    {
322        if (in_array(strtolower($type), $this->disallowedTypes)) {
323            unset($this->disallowedTypes[array_search(strtolower($type), $this->disallowedTypes)]);
324        }
325        return $this;
326    }
327
328    /**
329     * Set the overwrite flag
330     *
331     * @param  bool $overwrite
332     * @return Upload
333     */
334    public function overwrite(bool $overwrite): Upload
335    {
336        $this->overwrite = (bool)$overwrite;
337        return $this;
338    }
339
340    /**
341     * Get the upload directory
342     *
343     * @return string
344     */
345    public function getUploadDir(): string
346    {
347        return $this->uploadDir;
348    }
349
350    /**
351     * Get uploaded file
352     *
353     * @return string|null
354     */
355    public function getUploadedFile(): string|null
356    {
357        return $this->uploadedFile;
358    }
359
360    /**
361     * Get uploaded file full path
362     *
363     * @return string
364     */
365    public function getUploadedFullPath(): string
366    {
367        return $this->uploadDir . DIRECTORY_SEPARATOR . $this->uploadedFile;
368    }
369
370    /**
371     * Get the max size allowed
372     *
373     * @return int
374     */
375    public function getMaxSize(): int
376    {
377        return $this->maxSize;
378    }
379
380    /**
381     * Get the disallowed file types
382     *
383     * @return array
384     */
385    public function getDisallowedTypes(): array
386    {
387        return $this->disallowedTypes;
388    }
389
390    /**
391     * Get the allowed file types
392     *
393     * @return array
394     */
395    public function getAllowedTypes(): array
396    {
397        return $this->allowedTypes;
398    }
399
400    /**
401     * Determine if a file type is allowed
402     *
403     * @param  string $ext
404     * @return bool
405     */
406    public function isAllowed(string $ext): bool
407    {
408        $disallowed = ((count($this->disallowedTypes) > 0) && (in_array(strtolower($ext), $this->disallowedTypes)));
409        $allowed    = ((count($this->allowedTypes) == 0) || (in_array(strtolower($ext), $this->allowedTypes)));
410
411        return ((!$disallowed) && ($allowed));
412    }
413
414    /**
415     * Determine if a file type is not allowed
416     *
417     * @param  string $ext
418     * @return bool
419     */
420    public function isNotAllowed(string $ext): bool
421    {
422        $disallowed = ((count($this->disallowedTypes) > 0) && (in_array(strtolower($ext), $this->disallowedTypes)));
423        $allowed    = ((count($this->allowedTypes) == 0) || (in_array(strtolower($ext), $this->allowedTypes)));
424
425        return (($disallowed) && (!$allowed));
426    }
427
428    /**
429     * Determine if the overwrite flag is set
430     *
431     * @return bool
432     */
433    public function isOverwrite(): bool
434    {
435        return $this->overwrite;
436    }
437
438    /**
439     * Determine if the upload was a success
440     *
441     * @return bool
442     */
443    public function isSuccess(): bool
444    {
445        return ($this->error == UPLOAD_ERR_OK);
446    }
447
448    /**
449     * Determine if the upload was an error
450     *
451     * @return bool
452     */
453    public function isError(): bool
454    {
455        return ($this->error != UPLOAD_ERR_OK);
456    }
457
458    /**
459     * Get the upload error code
460     *
461     * @return int
462     */
463    public function getErrorCode(): int
464    {
465        return $this->error;
466    }
467
468    /**
469     * Get the upload error message
470     *
471     * @return string
472     */
473    public function getErrorMessage(): string
474    {
475        return self::$errorMessages[$this->error];
476    }
477
478    /**
479     * Check if filename exists in the upload directory
480     *
481     * @param  string $file
482     * @return bool
483     */
484    public function fileExists(string $file): bool
485    {
486        return (file_exists($this->uploadDir . DIRECTORY_SEPARATOR . $file));
487    }
488
489    /**
490     * Check filename for duplicates, returning a new filename appended with _#
491     *
492     * @param  string $file
493     * @return string
494     */
495    public function checkFilename(string $file): string
496    {
497        $newFilename  = $file;
498        $parts        = pathinfo($file);
499        $origFilename = $parts['filename'];
500        $ext          = (isset($parts['extension']) && ($parts['extension'] != '')) ? '.' . $parts['extension'] : null;
501        $i            = 1;
502
503        while ($this->fileExists($newFilename)) {
504            $newFilename = $origFilename . '_' . $i . $ext;
505            $i++;
506        }
507
508        return $newFilename;
509    }
510
511    /**
512     * Test a file upload before moving it
513     *
514     * @param  array $file
515     * @return bool
516     */
517    public function test(array $file): bool
518    {
519        if ($this->error != 0) {
520            return false;
521        } else {
522            if (!isset($file['error']) || !isset($file['size']) || !isset($file['tmp_name']) || !isset($file['name'])) {
523                return false;
524            } else {
525                $uploadedFile = UploadedFile::fromFile($file);
526                $this->error  = $uploadedFile->getError();
527                if ($this->error != 0) {
528                    return false;
529                } else {
530                    $fileSize  = $uploadedFile->getSize();
531                    $fileParts = pathinfo($uploadedFile->getClientFilename());
532                    $ext       = (isset($fileParts['extension'])) ? $fileParts['extension'] : null;
533
534                    if (($this->maxSize > 0) && ($fileSize > $this->maxSize)) {
535                        $this->error = self::UPLOAD_ERR_USER_SIZE;
536                        return false;
537                    } else if (($ext !== null) && (!$this->isAllowed($ext))) {
538                        $this->error = self::UPLOAD_ERR_NOT_ALLOWED;
539                        return false;
540                    } else {
541                        return true;
542                    }
543                }
544            }
545        }
546    }
547
548    /**
549     * Upload file to the upload dir, returns the newly uploaded file
550     *
551     * @param  array   $file
552     * @param  ?string $to
553     * @param  bool    $secure
554     * @return mixed
555     */
556    public function upload(array $file, ?string $to = null, bool $secure = true): mixed
557    {
558        if ($this->test($file)) {
559            if ($to === null) {
560                $to = $file['name'];
561            }
562            if (!$this->overwrite) {
563                $to = $this->checkFilename($to);
564            }
565
566            $this->uploadedFile = $to;
567            $to = $this->uploadDir . DIRECTORY_SEPARATOR . $to;
568
569            $uploadedFile = UploadedFile::fromFile($file);
570            $isUploaded   = is_uploaded_file($file['tmp_name']);
571
572            if ($secure && !$isUploaded) {
573                $this->error = self::UPLOAD_ERR_FILE_NOT_SECURE;
574                return false;
575            }
576
577            // The exception's message is deliberately discarded: Upload already decided which error
578            // code to report by checking is_uploaded_file() itself above, before calling moveTo(), so
579            // nothing diagnostic is lost by not inspecting what moveTo() throws.
580            try {
581                $uploadedFile->moveTo($to, $secure);
582            } catch (\Exception) {
583                $this->error = self::UPLOAD_ERR_UNEXPECTED;
584                return false;
585            }
586
587            return $this->uploadedFile;
588        } else {
589            return false;
590        }
591    }
592
593}