Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.62% covered (success)
85.62%
125 / 146
50.00% covered (warning)
50.00%
15 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
Local
85.62% covered (success)
85.62%
125 / 146
50.00% covered (warning)
50.00%
15 / 30
103.50
0.00% covered (danger)
0.00%
0 / 1
 mkdir
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 rmdir
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 listDirs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 listFiles
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 listDirsFlat
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 listFilesFlat
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 walkRecursive
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 putFile
75.00% covered (success)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 putFileContents
50.00% covered (warning)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 putFileStream
77.78% covered (success)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 uploadFile
75.00% covered (success)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 copyFile
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 copyFileToExternal
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 copyFileFromExternal
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 moveFileToExternal
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 moveFileFromExternal
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 renameFile
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 replaceFileContents
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 deleteFile
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 fetchFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fetchFileStream
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fetchFileInfo
75.00% covered (success)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getTemporaryUrl
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
 isDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileSize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFileType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFileMTime
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 md5File
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
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\Storage\Adapter;
16
17use Pop\Dir\Dir;
18use Pop\Utils\File;
19use Pop\Storage\Exception\DirectoryNotFoundException;
20use Pop\Storage\Exception\FileNotFoundException;
21use Pop\Storage\Exception\PathTraversalException;
22use Pop\Storage\Exception\UnableToCopyFileException;
23use Pop\Storage\Exception\UnableToCreateDirectoryException;
24use Pop\Storage\Exception\UnableToDeleteDirectoryException;
25use Pop\Storage\Exception\UnableToDeleteFileException;
26use Pop\Storage\Exception\UnableToMoveFileException;
27use Pop\Storage\Exception\UnableToWriteFileException;
28use Pop\Storage\Exception\UnsupportedOperationException;
29
30/**
31 * Storage adapter local class
32 *
33 * @category   Pop
34 * @package    Pop\Storage
35 * @author     Nick Sagona, III <nick@popphp.org>
36 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
37 * @license    https://www.popphp.org/license     New BSD License
38 * @version    3.0.0
39 */
40class Local extends AbstractAdapter
41{
42
43    /**
44     * Make directory
45     *
46     * @param  string $directory
47     * @return void
48     */
49    public function mkdir(string $directory): void
50    {
51        $path = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($directory);
52        if (!@mkdir($path)) {
53            throw new UnableToCreateDirectoryException('Error: Unable to create directory \'' . $path . '\'.');
54        }
55    }
56
57    /**
58     * Remove a directory
59     *
60     * @param  string $directory
61     * @throws DirectoryNotFoundException
62     * @throws UnableToDeleteDirectoryException
63     * @return void
64     */
65    public function rmdir(string $directory): void
66    {
67        $path = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($directory);
68        if (!is_dir($path)) {
69            throw new DirectoryNotFoundException('Error: The directory \'' . $path . '\' was not found.');
70        }
71
72        try {
73            $dir = new Dir($path);
74            $dir->emptyDir(true);
75        } catch (\Pop\Dir\Exception $exception) {
76            throw new UnableToDeleteDirectoryException(
77                'Error: Unable to delete directory \'' . $path . '\'.', 0, $exception
78            );
79        }
80    }
81
82    /**
83     * List directories
84     *
85     * @param  ?string $search
86     * @param  bool    $recursive
87     * @return array
88     */
89    public function listDirs(?string $search = null, bool $recursive = false): array
90    {
91        $directories = $recursive ? $this->walkRecursive(true) : $this->listDirsFlat();
92
93        if ($search !== null) {
94            $directories = $this->searchFilter($directories, $search);
95        }
96
97        return $directories;
98    }
99
100    /**
101     * List files
102     *
103     * @param  ?string $search
104     * @param  bool    $recursive
105     * @return array
106     */
107    public function listFiles(?string $search = null, bool $recursive = false): array
108    {
109        $files = $recursive ? $this->walkRecursive(false) : $this->listFilesFlat();
110
111        if ($search !== null) {
112            $files = $this->searchFilter($files, $search);
113        }
114
115        return $files;
116    }
117
118    /**
119     * List directories (flat, top-level only)
120     *
121     * @return array
122     */
123    private function listDirsFlat(): array
124    {
125        $directory = $this->directory;
126        return array_map(function($value) {
127            return $value. DIRECTORY_SEPARATOR;
128        }, array_values(array_filter(scandir($directory), function($value) use ($directory) {
129            return (($value != '.') && ($value != '..') && file_exists($directory . DIRECTORY_SEPARATOR . $value) &&
130                is_dir($directory . DIRECTORY_SEPARATOR . $value));
131        })));
132    }
133
134    /**
135     * List files (flat, top-level only)
136     *
137     * @return array
138     */
139    private function listFilesFlat(): array
140    {
141        $directory = $this->directory;
142        return array_values(array_filter(scandir($directory), function($value) use ($directory) {
143            return (($value != '.') && ($value != '..') && file_exists($directory . DIRECTORY_SEPARATOR . $value) &&
144                !is_dir($directory . DIRECTORY_SEPARATOR . $value) && is_file($directory . DIRECTORY_SEPARATOR . $value));
145        }));
146    }
147
148    /**
149     * Recursively walk the current directory, returning relative paths (with a trailing
150     * separator for directories) for every entry below it
151     *
152     * @param  bool $directoriesOnly
153     * @return array
154     */
155    private function walkRecursive(bool $directoriesOnly): array
156    {
157        $results  = [];
158        $iterator = new \RecursiveIteratorIterator(
159            new \RecursiveDirectoryIterator($this->directory, \RecursiveDirectoryIterator::SKIP_DOTS),
160            \RecursiveIteratorIterator::SELF_FIRST
161        );
162
163        foreach ($iterator as $fileInfo) {
164            $relativePath = substr((string) $fileInfo, strlen($this->directory) + 1);
165            if ($fileInfo->isDir()) {
166                if ($directoriesOnly) {
167                    $results[] = $relativePath . DIRECTORY_SEPARATOR;
168                }
169            } elseif (!$directoriesOnly) {
170                $results[] = $relativePath;
171            }
172        }
173
174        return $results;
175    }
176
177    /**
178     * Put file
179     *
180     * @param  string $fileFrom
181     * @param  bool   $copy
182     * @return void
183     */
184    public function putFile(string $fileFrom, bool $copy = true): void
185    {
186        if (!file_exists($fileFrom)) {
187            throw new FileNotFoundException('Error: The file \'' . $fileFrom . '\' was not found.');
188        }
189
190        $destination = $this->directory . DIRECTORY_SEPARATOR . basename($fileFrom);
191
192        if ($copy) {
193            if (!@copy($fileFrom, $destination)) {
194                throw new UnableToWriteFileException('Error: Unable to write file \'' . $fileFrom . '\'.');
195            }
196        } else {
197            if (!@rename($fileFrom, $destination)) {
198                throw new UnableToWriteFileException('Error: Unable to write file \'' . $fileFrom . '\'.');
199            }
200        }
201    }
202
203    /**
204     * Put file contents
205     *
206     * @param  string $filename
207     * @param  string $fileContents
208     * @return void
209     */
210    public function putFileContents(string $filename, string $fileContents): void
211    {
212        if (@file_put_contents($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename), $fileContents) === false) {
213            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
214        }
215    }
216
217    /**
218     * Put file from a stream resource
219     *
220     * @param  string $filename
221     * @throws UnableToWriteFileException
222     * @return void
223     */
224    public function putFileStream(string $filename, mixed $resource): void
225    {
226        if (!is_resource($resource)) {
227            throw new UnableToWriteFileException('Error: The provided resource is not a valid stream.');
228        }
229
230        $destination = @fopen($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename), 'w');
231        if ($destination === false) {
232            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
233        }
234
235        $copied = stream_copy_to_stream($resource, $destination);
236        fclose($destination);
237
238        if ($copied === false) {
239            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
240        }
241    }
242
243    /**
244     * Upload file from server request $_FILES['files']
245     *
246     * @param  array $file
247     * @throws UnableToWriteFileException|PathTraversalException
248     * @return void
249     */
250    public function uploadFile(array $file): void
251    {
252        if (!isset($file['tmp_name']) || !isset($file['name'])) {
253            throw new UnableToWriteFileException('Error: The uploaded file array was not valid.');
254        }
255
256        $destination = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($file['name']);
257        $moved = is_uploaded_file($file['tmp_name'])
258            ? @move_uploaded_file($file['tmp_name'], $destination)
259            : @rename($file['tmp_name'], $destination);
260
261        if (!$moved) {
262            throw new UnableToWriteFileException('Error: Unable to write file \'' . $file['name'] . '\'.');
263        }
264    }
265
266    /**
267     * Copy file
268     *
269     * @param  string $sourceFile
270     * @param  string $destFile
271     * @return void
272     */
273    public function copyFile(string $sourceFile, string $destFile): void
274    {
275        $sourceFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($sourceFile);
276        $destFile   = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($destFile);
277        if (!file_exists($sourceFile)) {
278            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
279        }
280        if (!@copy($sourceFile, $destFile)) {
281            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $sourceFile . '\'.');
282        }
283    }
284
285    /**
286     * Copy file to a location external to the current location
287     *
288     * @param  string $sourceFile
289     * @param  string $externalFile
290     * @return void
291     */
292    public function copyFileToExternal(string $sourceFile, string $externalFile): void
293    {
294        $sourceFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($sourceFile);
295        if (!file_exists($sourceFile)) {
296            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
297        }
298        if (!@copy($sourceFile, $externalFile)) {
299            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $sourceFile . '\'.');
300        }
301    }
302
303    /**
304     * Copy file from a location external to the current location
305     *
306     * @param  string $externalFile
307     * @param  string $destFile
308     * @return void
309     */
310    public function copyFileFromExternal(string $externalFile, string $destFile): void
311    {
312        $destFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($destFile);
313        if (!file_exists($externalFile)) {
314            throw new FileNotFoundException('Error: The file \'' . $externalFile . '\' was not found.');
315        }
316        if (!@copy($externalFile, $destFile)) {
317            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $externalFile . '\'.');
318        }
319    }
320
321    /**
322     * Move file to a location external to the current location
323     *
324     * @param  string $sourceFile
325     * @param  string $externalFile
326     * @return void
327     */
328    public function moveFileToExternal(string $sourceFile, string $externalFile): void
329    {
330        $sourceFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($sourceFile);
331        if (!file_exists($sourceFile)) {
332            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
333        }
334        if (!@rename($sourceFile, $externalFile)) {
335            throw new UnableToMoveFileException('Error: Unable to move file \'' . $sourceFile . '\'.');
336        }
337    }
338
339    /**
340     * Move file from a location external to the current location
341     *
342     * @param  string $externalFile
343     * @param  string $destFile
344     * @return void
345     */
346    public function moveFileFromExternal(string $externalFile, string $destFile): void
347    {
348        $destFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($destFile);
349        if (!file_exists($externalFile)) {
350            throw new FileNotFoundException('Error: The file \'' . $externalFile . '\' was not found.');
351        }
352        if (!@rename($externalFile, $destFile)) {
353            throw new UnableToMoveFileException('Error: Unable to move file \'' . $externalFile . '\'.');
354        }
355    }
356
357    /**
358     * Rename file
359     *
360     * @param  string $oldFile
361     * @param  string $newFile
362     * @return void
363     */
364    public function renameFile(string $oldFile, string $newFile): void
365    {
366        $oldFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($oldFile);
367        $newFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($newFile);
368        if (!file_exists($oldFile)) {
369            throw new FileNotFoundException('Error: The file \'' . $oldFile . '\' was not found.');
370        }
371        if (!@rename($oldFile, $newFile)) {
372            throw new UnableToMoveFileException('Error: Unable to move file \'' . $oldFile . '\'.');
373        }
374    }
375
376    /**
377     * Replace file
378     *
379     * @param  string $filename
380     * @param  string $fileContents
381     * @return void
382     */
383    public function replaceFileContents(string $filename, string $fileContents): void
384    {
385        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
386        if (!file_exists($filename)) {
387            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
388        }
389        if (@file_put_contents($filename, $fileContents) === false) {
390            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
391        }
392    }
393
394    /**
395     * Delete file
396     *
397     * @param  string $filename
398     * @return void
399     */
400    public function deleteFile(string $filename): void
401    {
402        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
403        if (!file_exists($filename)) {
404            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
405        }
406        if (!@unlink($filename)) {
407            throw new UnableToDeleteFileException('Error: Unable to delete file \'' . $filename . '\'.');
408        }
409    }
410
411    /**
412     * Fetch file
413     *
414     * @param  string $filename
415     * @return mixed
416     */
417    public function fetchFile(string $filename): mixed
418    {
419        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
420        if (!file_exists($filename)) {
421            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
422        }
423        return file_get_contents($filename);
424    }
425
426    /**
427     * Fetch file as a stream resource
428     *
429     * @param  string $filename
430     * @throws FileNotFoundException
431     * @return mixed
432     */
433    public function fetchFileStream(string $filename): mixed
434    {
435        $path = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
436        if (!file_exists($path)) {
437            throw new FileNotFoundException('Error: The file \'' . $path . '\' was not found.');
438        }
439
440        return fopen($path, 'r');
441    }
442
443    /**
444     * Fetch file info
445     *
446     * @param  string $filename
447     * @return array
448     */
449    public function fetchFileInfo(string $filename): array
450    {
451        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
452        if (!file_exists($filename)) {
453            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
454        }
455        return (new File($filename))->toArray();
456    }
457
458    /**
459     * Get a temporary (presigned) URL for the file, valid for $expiresInSeconds
460     *
461     * @param  string $filename
462     * @param  int    $expiresInSeconds
463     * @throws UnsupportedOperationException
464     * @return string
465     */
466    public function getTemporaryUrl(string $filename, int $expiresInSeconds = 900): string
467    {
468        throw new UnsupportedOperationException('Error: Temporary URLs are not supported by the local disk adapter.');
469    }
470
471    /**
472     * File exists
473     *
474     * @param  string $filename
475     * @return bool
476     */
477    public function fileExists(string $filename): bool
478    {
479        return file_exists($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename));
480    }
481
482    /**
483     * Check if is a dir
484     *
485     * @param  string $directory
486     * @return bool
487     */
488    public function isDir(string $directory): bool
489    {
490        return is_dir($this->directory . DIRECTORY_SEPARATOR . $this->scrub($directory));
491    }
492
493    /**
494     * Check if is a file
495     *
496     * @param  string $filename
497     * @return bool
498     */
499    public function isFile(string $filename): bool
500    {
501        return is_file($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename));
502    }
503
504    /**
505     * Get file size
506     *
507     * @param  string $filename
508     * @throws FileNotFoundException
509     * @return int
510     */
511    public function getFileSize(string $filename): int
512    {
513        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
514        if (!file_exists($filename)) {
515            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
516        }
517        return filesize($filename);
518    }
519
520    /**
521     * Get file type
522     *
523     * @param  string $filename
524     * @throws FileNotFoundException
525     * @return string
526     */
527    public function getFileType(string $filename): string
528    {
529        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
530        if (!file_exists($filename)) {
531            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
532        }
533        return filetype($filename);
534    }
535
536    /**
537     * Get file modified time
538     *
539     * @param  string $filename
540     * @throws FileNotFoundException
541     * @return int|string
542     */
543    public function getFileMTime(string $filename): int|string
544    {
545        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
546        if (!file_exists($filename)) {
547            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
548        }
549        return filemtime($filename);
550    }
551
552    /**
553     * Create MD5 checksum of the file
554     *
555     * @param  string $filename
556     * @throws FileNotFoundException
557     * @return string
558     */
559    public function md5File(string $filename): string
560    {
561        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
562        if (!file_exists($filename)) {
563            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
564        }
565        return md5_file($filename);
566    }
567    
568}