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