Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.31% covered (success)
92.31%
204 / 221
60.00% covered (warning)
60.00%
15 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
StoredFile
92.31% covered (success)
92.31%
204 / 221
60.00% covered (warning)
60.00%
15 / 25
115.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __clone
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 validateState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markBeingStored
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 createFromStorage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 createFromString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createFromLocalFile
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createFromResource
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 createFromUploadedFile
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 createFromDto
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 getStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 getIndexing
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
16.06
 getStoragePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeRewindable
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 getStream
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 moveTo
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
8.02
 getSize
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
9.24
 getError
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getClientFilename
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
8.04
 getClientMediaType
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 getServerMimeType
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
11.20
 getServerPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIndexing
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2namespace Apie\Core\FileStorage;
3
4use Apie\Core\Dto\DtoInterface;
5use Apie\Core\Enums\UploadedFileStatus;
6use Apie\Core\ValueObjects\Utils;
7use Apie\CountWords\WordCounter;
8use finfo;
9use Nyholm\Psr7\Stream;
10use Psr\Http\Message\StreamInterface;
11use Psr\Http\Message\UploadedFileInterface;
12use ReflectionClass;
13use RuntimeException;
14use Symfony\Component\Mime\MimeTypes;
15
16class StoredFile implements UploadedFileInterface
17{
18    private ?string $movedPath = null;
19    /**
20     * @param resource|null $resource
21     * @param array<string, int>|null $indexing
22     */
23    final protected function __construct(
24        protected UploadedFileStatus $status,
25        protected ?FileStorageInterface $storage = null,
26        protected ?string $content = null,
27        protected ?string $storagePath = null,
28        protected mixed $resource = null,
29        protected ?string $clientMimeType = null,
30        protected ?string $clientOriginalFile = null,
31        protected ?int $fileSize = null,
32        protected ?string $serverMimeType = null,
33        protected ?string $serverPath = null,
34        protected ?UploadedFileInterface $internalFile = null,
35        protected ?array $indexing = null,
36        protected bool $removeOnDestruct = false,
37    ) {
38        $this->validateState();
39    }
40
41    public function __clone(): void
42    {
43        if ($this->resource) {
44            $this->resource = $this->getStream()->detach();
45        }
46        $this->removeOnDestruct = false;
47    }
48
49    protected function validateState(): void
50    {
51    }
52
53    public function markBeingStored(FileStorageInterface $storage, string $storagePath): static
54    {
55        $this->status = UploadedFileStatus::StoredInStorage;
56        $this->storage = $storage;
57        $this->storagePath = $storagePath;
58        return $this;
59    }
60
61    final public function __destruct()
62    {
63        if ($this->removeOnDestruct && $this->serverPath && file_exists($this->serverPath)) {
64            unlink($this->serverPath);
65        }
66    }
67
68    final public static function createFromStorage(FileStorageInterface $storage, string $storagePath): static
69    {
70        return new static(
71            UploadedFileStatus::StoredInStorage,
72            storage: $storage,
73            storagePath: $storagePath
74        );
75    }
76
77    final public static function createFromString(
78        string $content,
79        ?string $clientMimeType = null,
80        ?string $clientOriginalFile = null
81    ): static {
82        return new static(
83            UploadedFileStatus::CreatedLocally,
84            content: $content,
85            clientMimeType: $clientMimeType,
86            clientOriginalFile: $clientOriginalFile
87        );
88    }
89
90    final public static function createFromLocalFile(string $serverPath, ?string $clientMimeType = null, bool $removeOnDestruct = false): static
91    {
92        return new static(
93            UploadedFileStatus::CreatedLocally,
94            clientMimeType: $clientMimeType,
95            serverPath: $serverPath,
96            removeOnDestruct: $removeOnDestruct
97        );
98    }
99
100    /**
101     * @param resource $resource
102     */
103    final public static function createFromResource(
104        mixed $resource,
105        ?string $clientMimeType = null,
106        ?string $clientOriginalFile = null,
107    ): static {
108        assert(is_resource($resource));
109        assert('stream' === get_resource_type($resource));
110        return new static(
111            UploadedFileStatus::FromRequest,
112            resource: $resource,
113            clientMimeType: $clientMimeType,
114            clientOriginalFile: $clientOriginalFile
115        );
116    }
117
118    final public static function createFromUploadedFile(UploadedFileInterface $uploadedFile, ?string $storagePath = null): static
119    {
120        if (get_class($uploadedFile) === static::class) {
121            return $uploadedFile;
122        }
123        if ($uploadedFile instanceof StoredFile) {
124            return new static(
125                status: $uploadedFile->status,
126                storage: $uploadedFile->storage,
127                content: $uploadedFile->content,
128                storagePath: $storagePath ?? $uploadedFile->storagePath,
129                resource: $uploadedFile->resource,
130                clientMimeType: $uploadedFile->clientMimeType,
131                clientOriginalFile: $uploadedFile->clientOriginalFile,
132                fileSize: $uploadedFile->fileSize,
133                serverMimeType:$uploadedFile->serverMimeType,
134                serverPath: $uploadedFile->serverPath,
135                internalFile: $uploadedFile,
136                indexing: $uploadedFile->indexing,
137                removeOnDestruct: false
138            );
139        }
140        return new static(
141            $storagePath === null ? UploadedFileStatus::FromRequest : UploadedFileStatus::StoredInStorage,
142            storagePath: $storagePath,
143            internalFile: $uploadedFile
144        );
145    }
146
147    final public static function createFromDto(DtoInterface $dto): static
148    {
149        $arguments = ['status' => UploadedFileStatus::StoredInStorage];
150        $props = get_object_vars($dto);
151        $constructor = (new ReflectionClass(StoredFile::class))->getConstructor();
152        assert($constructor !== null);
153        foreach ($constructor->getParameters() as $parameter) {
154            $name = $parameter->name;
155            if ($name === 'indexing') {
156                $val = ($props[$name] ?? null);
157                $arguments[$name] = $val === null ? null : Utils::toArray($val);
158            } elseif ($name !== 'status') {
159                $arguments[$name] = $props[$name] ?? $parameter->getDefaultValue();
160            }
161        }
162        return new static(...$arguments);
163    }
164
165    final public function getStatus(): UploadedFileStatus
166    {
167        return $this->status;
168    }
169
170    final public function getContent(): string
171    {
172        if ($this->content !== null) {
173            return $this->content;
174        }
175        if ($this->serverPath && file_exists($this->serverPath)) {
176            $this->content = file_get_contents($this->serverPath);
177            return $this->content;
178        }
179        if (is_resource($this->resource)) {
180            $this->content = stream_get_contents($this->resource);
181            return $this->content;
182        }
183        $internalFile = $this->internalFile;
184        if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$internalFile) {
185            $internalFile = $this->storage->pathToPsr($this->storagePath);
186        }
187        if ($internalFile !== null) {
188            if ($internalFile instanceof StoredFile) {
189                return $this->content = $internalFile->getContent();
190            }
191            return $this->content = $internalFile->getStream()->__toString();
192        }
193
194        throw new \LogicException('Could not load content');
195    }
196
197    /**
198     * @return array<string, int>
199     */
200    public function getIndexing(): array
201    {
202        if (null !== $this->indexing) {
203            return $this->indexing;
204        }
205        if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$this->internalFile) {
206            $file = $this->storage->pathToPsr($this->storagePath);
207            if ($file instanceof StoredFile) {
208                return $this->indexing = $file->getIndexing();
209            }
210            $this->internalFile = $file;
211        }
212        if ($this->internalFile instanceof StoredFile) {
213            return $this->indexing = $this->internalFile->getIndexing();
214        } elseif ($this->internalFile instanceof UploadedFileInterface && !is_resource($this->resource) && $this->internalFile->getStream()->isReadable()) {
215            $this->resource = $this->makeRewindable($this->internalFile->getStream()->detach());
216        }
217        $extension = null;
218        if ($this->clientOriginalFile !== null) {
219            $extension = pathinfo($this->clientOriginalFile, PATHINFO_EXTENSION) ? : null;
220        }
221        if ($this->serverPath && file_exists($this->serverPath)) {
222            return $this->indexing = WordCounter::countFromFile(
223                $this->serverPath,
224                mimeType: $this->getServerMimeType()
225            );
226        }
227        if (is_resource($this->resource)) {
228            return $this->indexing = WordCounter::countFromResource(
229                $this->resource,
230                mimeType: $this->getServerMimeType(),
231                extension: $extension
232            );
233        }
234        if ($this->content !== null) {
235            return $this->indexing = WordCounter::countFromString(
236                $this->content,
237                mimeType: $this->getServerMimeType(),
238                extension: $extension
239            );
240        }
241        return $this->indexing = [];
242    }
243
244    final public function getStoragePath(): ?string
245    {
246        return $this->storagePath;
247    }
248
249    /**
250     * @param resource $resource
251     * @return resource
252     */
253    private function makeRewindable(mixed $resource): mixed
254    {
255        $tempStream = tmpfile();
256        if ($tempStream === false) {
257            throw new RuntimeException('Unable to create a temporary file');
258        }
259        @rewind($resource);
260        if (false === stream_copy_to_stream($resource, $tempStream)) {
261            throw new \RuntimeException('Could not copy stream');
262        }
263        if (!rewind($tempStream)) {
264            throw new \RuntimeException('Could not rewind stream');
265        }
266
267        return $tempStream;
268    }
269
270    final public function getStream(): StreamInterface
271    {
272        if ($this->content !== null) {
273            return Stream::create($this->content);
274        }
275        if ($this->serverPath) {
276            return new Stream(fopen($this->serverPath, 'r'));
277        }
278        if (is_resource($this->resource)) {
279            $meta = stream_get_meta_data($this->resource);
280            if (!$meta['seekable']) {
281                $this->resource = $this->makeRewindable($this->resource);
282            }
283            rewind($this->resource);
284            return new Stream($this->makeRewindable($this->resource));
285        }
286        if ($this->storage instanceof ChainedFileStorage) {
287            $this->internalFile = $this->storage->pathToPsr($this->storagePath);
288        }
289        if (null !== $this->internalFile) {
290            $this->resource = $this->makeRewindable($this->internalFile->getStream()->detach());
291            return new Stream($this->makeRewindable($this->resource));
292        }
293        throw new \LogicException("I have no idea how to make a stream for this uploaded file");
294    }
295
296    public function moveTo(string $targetPath): void
297    {
298        if ($this->movedPath !== null) {
299            throw new \LogicException('File is already moved to ' . $this->movedPath);
300        }
301        if ($this->serverPath !== null && !$this->removeOnDestruct) {
302            throw new \LogicException($this->serverPath . ' is not a temporary file');
303        }
304        $this->movedPath = $targetPath;
305        if ($this->storage instanceof ChainedFileStorage) {
306            $this->internalFile = $this->storage->pathToPsr($this->storagePath);
307        }
308        if ($this->internalFile) {
309            $this->internalFile->moveTo($targetPath);
310            return;
311        }
312        if ($this->content !== null) {
313            file_put_contents($targetPath, $this->content);
314        }
315        if ($this->serverPath !== null) {
316            move_uploaded_file($this->serverPath, $targetPath);
317        }
318    }
319    final public function getSize(): ?int
320    {
321        if ($this->fileSize !== null) {
322            return $this->fileSize;
323        }
324        if (isset($this->resource) || $this->storage instanceof ChainedFileStorage) {
325            return $this->fileSize = $this->getStream()->getSize();
326        }
327        if ($this->content !== null) {
328            return $this->fileSize = strlen($this->content);
329        }
330        if ($this->serverPath) {
331            $size = @filesize($this->serverPath);
332            // size < 0 is possible on 32bit systems with files larger than 2GB.
333            if ($size === false || $size < 0) {
334                return null;
335            }
336            return $size;
337        }
338        if ($this->internalFile) {
339            return $this->fileSize = $this->internalFile->getSize();
340        }
341
342        return null;
343    }
344    final public function getError(): int
345    {
346        if (null !== $this->internalFile) {
347            return $this->internalFile->getError();
348        }
349        if ($this->serverPath) {
350            return file_exists($this->serverPath) ? UPLOAD_ERR_OK : UPLOAD_ERR_NO_FILE;
351        }
352        return UPLOAD_ERR_OK;
353    }
354    final public function getClientFilename(): ?string
355    {
356        if ($this->clientOriginalFile !== null) {
357            return $this->clientOriginalFile;
358        }
359        if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$this->internalFile) {
360            $file = $this->storage->pathToPsr($this->storagePath);
361            if ($file instanceof StoredFile) {
362                return $this->clientOriginalFile = $file->getClientFilename();
363            }
364            $this->internalFile = $file;
365        }
366        if (null !== $this->internalFile) {
367            return $this->clientOriginalFile = $this->internalFile->getClientFilename();
368        }
369        if ($this->serverPath !== null) {
370            return $this->clientOriginalFile = basename($this->serverPath);
371        }
372        return null;
373    }
374    final public function getClientMediaType(): ?string
375    {
376        if ($this->clientMimeType !== null) {
377            return $this->clientMimeType;
378        }
379        if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$this->internalFile) {
380            $file = $this->storage->pathToPsr($this->storagePath);
381            if ($file instanceof StoredFile) {
382                return $this->clientMimeType = $file->getClientMediaType();
383            }
384            $this->internalFile = $file;
385        }
386        if (null !== $this->internalFile) {
387            return $this->clientMimeType = $this->internalFile->getClientMediaType();
388        }
389        return $this->clientMimeType;
390    }
391
392    final public function getServerMimeType(): string
393    {
394        if (!$this->serverMimeType) {
395            if ($this->serverPath && file_exists($this->serverPath)) {
396                return $this->serverMimeType = MimeTypes::getDefault()->guessMimeType($this->serverPath);
397            }
398            if ($this->content) {
399                $finfo = new finfo();
400                return $this->serverMimeType = $finfo->buffer($this->content, FILEINFO_MIME_TYPE);
401            }
402            if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$this->internalFile) {
403                $file = $this->storage->pathToPsr($this->storagePath);
404                if ($file instanceof StoredFile) {
405                    return $this->serverMimeType = $file->getServerMimeType();
406                }
407                $this->internalFile = $file;
408            }
409            if (null !== $this->internalFile || isset($this->resource)) {
410                $content = $this->getContent();
411                $finfo = new finfo();
412                return $this->serverMimeType = $finfo->buffer($content, FILEINFO_MIME_TYPE);
413            }
414            return 'application/octet-stream';
415        }
416
417        return $this->serverMimeType;
418    }
419
420    final public function getServerPath(): ?string
421    {
422        return $this->serverPath;
423    }
424
425    /**
426     * @internal
427     * @param array<string, int> $indexing
428     */
429    final public function setIndexing(array $indexing): static
430    {
431        $this->indexing = $indexing;
432        return $this;
433    }
434}