Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.83% |
198 / 218 |
|
58.33% |
14 / 24 |
CRAP | |
0.00% |
0 / 1 |
| StoredFile | |
90.83% |
198 / 218 |
|
58.33% |
14 / 24 |
114.68 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validateState | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| markBeingStored | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| __destruct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
4 | |||
| createFromStorage | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| createFromString | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| createFromLocalFile | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| createFromResource | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| createFromUploadedFile | |
78.26% |
18 / 23 |
|
0.00% |
0 / 1 |
3.09 | |||
| createFromDto | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| getStatus | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getContent | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
10 | |||
| getIndexing | |
93.75% |
30 / 32 |
|
0.00% |
0 / 1 |
15.05 | |||
| getStoragePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| makeRewindable | |
66.67% |
6 / 9 |
|
0.00% |
0 / 1 |
4.59 | |||
| getStream | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
7.01 | |||
| moveTo | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
8.02 | |||
| getSize | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
9.24 | |||
| getError | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| getClientFilename | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
8.04 | |||
| getClientMediaType | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
7.05 | |||
| getServerMimeType | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
11.20 | |||
| getServerPath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setIndexing | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | namespace Apie\Core\FileStorage; |
| 3 | |
| 4 | use Apie\Core\Dto\DtoInterface; |
| 5 | use Apie\Core\Enums\UploadedFileStatus; |
| 6 | use Apie\Core\ValueObjects\Utils; |
| 7 | use Apie\CountWords\WordCounter; |
| 8 | use finfo; |
| 9 | use Nyholm\Psr7\Stream; |
| 10 | use Psr\Http\Message\StreamInterface; |
| 11 | use Psr\Http\Message\UploadedFileInterface; |
| 12 | use ReflectionClass; |
| 13 | use RuntimeException; |
| 14 | use Symfony\Component\Mime\MimeTypes; |
| 15 | |
| 16 | class 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 (isset($this->resource) || $this->storage instanceof ChainedFileStorage) { |
| 316 | return $this->fileSize = $this->getStream()->getSize(); |
| 317 | } |
| 318 | if ($this->content !== null) { |
| 319 | return $this->fileSize = strlen($this->content); |
| 320 | } |
| 321 | if ($this->serverPath) { |
| 322 | $size = @filesize($this->serverPath); |
| 323 | // size < 0 is possible on 32bit systems with files larger than 2GB. |
| 324 | if ($size === false || $size < 0) { |
| 325 | return null; |
| 326 | } |
| 327 | return $size; |
| 328 | } |
| 329 | if ($this->internalFile) { |
| 330 | return $this->fileSize = $this->internalFile->getSize(); |
| 331 | } |
| 332 | |
| 333 | return null; |
| 334 | } |
| 335 | final public function getError(): int |
| 336 | { |
| 337 | if (null !== $this->internalFile) { |
| 338 | return $this->internalFile->getError(); |
| 339 | } |
| 340 | if ($this->serverPath) { |
| 341 | return file_exists($this->serverPath) ? UPLOAD_ERR_OK : UPLOAD_ERR_NO_FILE; |
| 342 | } |
| 343 | return UPLOAD_ERR_OK; |
| 344 | } |
| 345 | final public function getClientFilename(): ?string |
| 346 | { |
| 347 | if ($this->clientOriginalFile !== null) { |
| 348 | return $this->clientOriginalFile; |
| 349 | } |
| 350 | if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$this->internalFile) { |
| 351 | $file = $this->storage->pathToPsr($this->storagePath); |
| 352 | if ($file instanceof StoredFile) { |
| 353 | return $this->clientOriginalFile = $file->getClientFilename(); |
| 354 | } |
| 355 | $this->internalFile = $file; |
| 356 | } |
| 357 | if (null !== $this->internalFile) { |
| 358 | return $this->clientOriginalFile = $this->internalFile->getClientFilename(); |
| 359 | } |
| 360 | if ($this->serverPath !== null) { |
| 361 | return $this->clientOriginalFile = basename($this->serverPath); |
| 362 | } |
| 363 | return null; |
| 364 | } |
| 365 | final public function getClientMediaType(): ?string |
| 366 | { |
| 367 | if ($this->clientMimeType !== null) { |
| 368 | return $this->clientMimeType; |
| 369 | } |
| 370 | if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$this->internalFile) { |
| 371 | $file = $this->storage->pathToPsr($this->storagePath); |
| 372 | if ($file instanceof StoredFile) { |
| 373 | return $this->clientMimeType = $file->getClientMediaType(); |
| 374 | } |
| 375 | $this->internalFile = $file; |
| 376 | } |
| 377 | if (null !== $this->internalFile) { |
| 378 | return $this->clientMimeType = $this->internalFile->getClientMediaType(); |
| 379 | } |
| 380 | return $this->clientMimeType; |
| 381 | } |
| 382 | |
| 383 | final public function getServerMimeType(): string |
| 384 | { |
| 385 | if (!$this->serverMimeType) { |
| 386 | if ($this->serverPath && file_exists($this->serverPath)) { |
| 387 | return $this->serverMimeType = MimeTypes::getDefault()->guessMimeType($this->serverPath); |
| 388 | } |
| 389 | if ($this->content) { |
| 390 | $finfo = new finfo(); |
| 391 | return $this->serverMimeType = $finfo->buffer($this->content, FILEINFO_MIME_TYPE); |
| 392 | } |
| 393 | if ($this->storage instanceof PsrAwareStorageInterface && $this->storagePath && !$this->internalFile) { |
| 394 | $file = $this->storage->pathToPsr($this->storagePath); |
| 395 | if ($file instanceof StoredFile) { |
| 396 | return $this->serverMimeType = $file->getServerMimeType(); |
| 397 | } |
| 398 | $this->internalFile = $file; |
| 399 | } |
| 400 | if (null !== $this->internalFile || isset($this->resource)) { |
| 401 | $content = $this->getContent(); |
| 402 | $finfo = new finfo(); |
| 403 | return $this->serverMimeType = $finfo->buffer($content, FILEINFO_MIME_TYPE); |
| 404 | } |
| 405 | return 'application/octet-stream'; |
| 406 | } |
| 407 | |
| 408 | return $this->serverMimeType; |
| 409 | } |
| 410 | |
| 411 | final public function getServerPath(): ?string |
| 412 | { |
| 413 | return $this->serverPath; |
| 414 | } |
| 415 | |
| 416 | /** |
| 417 | * @internal |
| 418 | * @param array<string, int> $indexing |
| 419 | */ |
| 420 | final public function setIndexing(array $indexing): static |
| 421 | { |
| 422 | $this->indexing = $indexing; |
| 423 | return $this; |
| 424 | } |
| 425 | } |