• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

plank / laravel-mediable / 8585433686

07 Apr 2024 02:04AM UTC coverage: 95.282% (-0.5%) from 95.789%
8585433686

push

github

web-flow
Merge pull request #343 from plank/v6

V6

338 of 375 new or added lines in 30 files covered. (90.13%)

1 existing line in 1 file now uncovered.

1434 of 1505 relevant lines covered (95.28%)

117.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.53
/src/MediaUploader.php
1
<?php
2
declare(strict_types=1);
3

4
namespace Plank\Mediable;
5

6
use Illuminate\Contracts\Filesystem\Filesystem;
7
use Illuminate\Filesystem\FilesystemManager;
8
use League\Flysystem\UnableToRetrieveMetadata;
9
use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException;
10
use Plank\Mediable\Exceptions\MediaUpload\FileExistsException;
11
use Plank\Mediable\Exceptions\MediaUpload\FileNotFoundException;
12
use Plank\Mediable\Exceptions\MediaUpload\FileNotSupportedException;
13
use Plank\Mediable\Exceptions\MediaUpload\FileSizeException;
14
use Plank\Mediable\Exceptions\MediaUpload\ForbiddenException;
15
use Plank\Mediable\Exceptions\MediaUpload\InvalidHashException;
16
use Plank\Mediable\Helpers\File;
17
use Plank\Mediable\SourceAdapters\RawContentAdapter;
18
use Plank\Mediable\SourceAdapters\SourceAdapterFactory;
19
use Plank\Mediable\SourceAdapters\SourceAdapterInterface;
20

21
/**
22
 * Media Uploader.
23
 *
24
 * Validates files, uploads them to disk and generates Media
25
 */
26
class MediaUploader
27
{
28
    const ON_DUPLICATE_UPDATE = 'update';
29
    const ON_DUPLICATE_INCREMENT = 'increment';
30
    const ON_DUPLICATE_ERROR = 'error';
31
    const ON_DUPLICATE_REPLACE = 'replace';
32
    const ON_DUPLICATE_REPLACE_WITH_VARIANTS = 'replace_with_variants';
33

34
    private FileSystemManager $filesystem;
35

36
    private SourceAdapterFactory $factory;
37

38
    private ImageManipulator $imageManipulator;
39

40
    private array $config;
41

42
    private SourceAdapterInterface $source;
43

44
    private ?string $disk = null;
45

46
    /**
47
     * Path relative to the filesystem disk root.
48
     */
49
    private ?string $directory = null;
50

51
    /**
52
     * Name of the new file.
53
     */
54
    private ?string $filename = null;
55

56
    /**
57
     * If true the contents hash of the source will be used as the filename.
58
     */
59
    private ?string $hashFilenameAlgo = null;
60

61
    /**
62
     * Visibility for the new file
63
     */
64
    private ?string $visibility = null;
65

66
    /**
67
     * Callable allowing to alter the model before save.
68
     * @var callable
69
     */
70
    private $before_save;
71

72
    /**
73
     * Additional options to pass to the filesystem while uploading
74
     */
75
    private array $options = [];
76

77
    private ?string $alt = null;
78

79
    private array $expectedHashes = [];
80

81
    /**
82
     * Constructor.
83
     * @param FilesystemManager $filesystem
84
     * @param SourceAdapterFactory $factory
85
     * @param array|null $config
86
     */
87
    public function __construct(
88
        FileSystemManager $filesystem,
89
        SourceAdapterFactory $factory,
90
        ImageManipulator $imageManipulator,
91
        array $config = null
92
    ) {
93
        $this->filesystem = $filesystem;
894✔
94
        $this->factory = $factory;
894✔
95
        $this->imageManipulator = $imageManipulator;
894✔
96
        $this->config = $config ?: config('mediable', []);
894✔
97
    }
98

99
    /**
100
     * Set the source for the file.
101
     *
102
     * @param  mixed $source
103
     *
104
     * @return $this
105
     * @throws ConfigurationException
106
     */
107
    public function fromSource(mixed $source): self
108
    {
109
        $this->source = $this->factory->create($source);
108✔
110

111
        return $this;
108✔
112
    }
113

114
    /**
115
     * Set the source for the string data.
116
     * @param  string $source
117
     * @return $this
118
     */
119
    public function fromString(string $source): self
120
    {
121
        $this->source = new RawContentAdapter($source);
6✔
122

123
        return $this;
6✔
124
    }
125

126
    /**
127
     * Set the filesystem disk and relative directory where the file will be saved.
128
     *
129
     * @param  string $disk
130
     * @param  string $directory
131
     *
132
     * @return $this
133
     * @throws ConfigurationException
134
     * @throws ForbiddenException
135
     */
136
    public function toDestination(string $disk, string $directory): self
137
    {
138
        return $this->toDisk($disk)->toDirectory($directory);
108✔
139
    }
140

141
    /**
142
     * Set the filesystem disk on which the file will be saved.
143
     *
144
     * @param string $disk
145
     *
146
     * @return $this
147
     * @throws ConfigurationException
148
     * @throws ForbiddenException
149
     */
150
    public function toDisk(string $disk): self
151
    {
152
        $this->disk = $this->verifyDisk($disk);
126✔
153

154
        return $this;
114✔
155
    }
156

157
    /**
158
     * Set the directory relative to the filesystem disk at which the file will be saved.
159
     * @param string $directory
160
     * @return $this
161
     */
162
    public function toDirectory(string $directory): self
163
    {
164
        $this->directory = File::sanitizePath($directory);
114✔
165

166
        return $this;
114✔
167
    }
168

169
    /**
170
     * Specify the filename to copy to the file to.
171
     * @param string $filename
172
     * @return $this
173
     */
174
    public function useFilename(string $filename): self
175
    {
176
        $this->filename = File::sanitizeFilename($filename);
84✔
177
        $this->hashFilenameAlgo = null;
84✔
178

179
        return $this;
84✔
180
    }
181

182
    public function withAltAttribute(string $alt): self
183
    {
184
        $this->alt = $alt;
12✔
185
        return $this;
12✔
186
    }
187

188
    /**
189
     * Indicates to the uploader to generate a filename using the file's MD5 hash.
190
     * @param string $algo any hashing algorithm supported by PHP's hash() function
191
     * @return $this
192
     */
193
    public function useHashForFilename(string $algo = 'md5'): self
194
    {
195
        $this->hashFilenameAlgo = $algo;
12✔
196
        $this->filename = null;
12✔
197

198
        return $this;
12✔
199
    }
200

201
    /**
202
     * Restore the default behaviour of using the source file's filename.
203
     * @return $this
204
     */
205
    public function useOriginalFilename(): self
206
    {
207
        $this->filename = null;
×
NEW
208
        $this->hashFilenameAlgo = null;
×
209

210
        return $this;
×
211
    }
212

213
    /**
214
     * Change the class to use for generated Media.
215
     * @param string $class
216
     * @return $this
217
     * @throws ConfigurationException if $class does not extend Plank\Mediable\Media
218
     */
219
    public function setModelClass(string $class): self
220
    {
221
        if (!is_subclass_of($class, Media::class)) {
12✔
222
            throw ConfigurationException::cannotSetModel($class);
6✔
223
        }
224
        $this->config['model'] = $class;
6✔
225

226
        return $this;
6✔
227
    }
228

229
    /**
230
     * Change the maximum allowed file size.
231
     * @param int $size
232
     * @return $this
233
     */
234
    public function setMaximumSize(int $size): self
235
    {
236
        $this->config['max_size'] = $size;
12✔
237

238
        return $this;
12✔
239
    }
240

241
    /**
242
     * Change the behaviour for when a file already exists at the destination.
243
     * @param string $behavior
244
     * @return $this
245
     */
246
    public function setOnDuplicateBehavior(string $behavior): self
247
    {
248
        $this->config['on_duplicate'] = $behavior;
48✔
249

250
        return $this;
48✔
251
    }
252

253
    /**
254
     * Get current behavior when duplicate file is uploaded.
255
     *
256
     * @return string
257
     */
258
    public function getOnDuplicateBehavior(): string
259
    {
260
        return $this->config['on_duplicate'];
6✔
261
    }
262

263
    /**
264
     * Throw an exception when file already exists at the destination.
265
     *
266
     * @return $this
267
     */
268
    public function onDuplicateError(): self
269
    {
270
        return $this->setOnDuplicateBehavior(self::ON_DUPLICATE_ERROR);
6✔
271
    }
272

273
    /**
274
     * Append incremented counter to file name when file already exists at destination.
275
     *
276
     * @return $this
277
     */
278
    public function onDuplicateIncrement(): self
279
    {
280
        return $this->setOnDuplicateBehavior(self::ON_DUPLICATE_INCREMENT);
12✔
281
    }
282

283
    /**
284
     * Overwrite existing Media when file already exists at destination.
285
     *
286
     * This will delete the old media record and create a new one, detaching any existing associations.
287
     *
288
     * @return $this
289
     */
290
    public function onDuplicateReplace(): self
291
    {
292
        return $this->setOnDuplicateBehavior(self::ON_DUPLICATE_REPLACE);
18✔
293
    }
294

295
    /**
296
     * Overwrite existing Media when file already exists at destination and delete any variants of the original record.
297
     *
298
     * This will delete the old media record and create a new one, detaching any existing associations.
299
     *
300
     * This will also delete any existing
301
     *
302
     * @return $this
303
     */
304
    public function onDuplicateReplaceWithVariants(): self
305
    {
306
        return $this->setOnDuplicateBehavior(self::ON_DUPLICATE_REPLACE_WITH_VARIANTS);
12✔
307
    }
308

309
    /**
310
     * Overwrite existing files and update the existing media record.
311
     *
312
     * This will retain any existing associations.
313
     *
314
     * @return $this
315
     */
316
    public function onDuplicateUpdate(): self
317
    {
318
        return $this->setOnDuplicateBehavior(self::ON_DUPLICATE_UPDATE);
18✔
319
    }
320

321
    /**
322
     * Change whether both the MIME type and extensions must match the same aggregate type.
323
     * @param bool $strict
324
     * @return $this
325
     */
326
    public function setStrictTypeChecking(bool $strict): self
327
    {
328
        $this->config['strict_type_checking'] = $strict;
6✔
329

330
        return $this;
6✔
331
    }
332

333
    /**
334
     * Change whether files not matching any aggregate types are allowed.
335
     * @param bool $allow
336
     * @return $this
337
     */
338
    public function setAllowUnrecognizedTypes(bool $allow): self
339
    {
340
        $this->config['allow_unrecognized_types'] = $allow;
18✔
341

342
        return $this;
18✔
343
    }
344

345
    /**
346
     * Add or update the definition of a aggregate type.
347
     * @param string $type the name of the type
348
     * @param string[] $mimeTypes list of MIME types recognized
349
     * @param string[] $extensions list of file extensions recognized
350
     * @return $this
351
     */
352
    public function setTypeDefinition(string $type, array $mimeTypes, array $extensions): self
353
    {
354
        $this->config['aggregate_types'][$type] = [
24✔
355
            'mime_types' => array_map('strtolower', $mimeTypes),
24✔
356
            'extensions' => array_map('strtolower', $extensions),
24✔
357
        ];
24✔
358

359
        return $this;
24✔
360
    }
361

362
    /**
363
     * Set a list of MIME types that the source file must be restricted to.
364
     * @param string[] $allowedMimes
365
     * @return $this
366
     */
367
    public function setAllowedMimeTypes(array $allowedMimes): self
368
    {
369
        $this->config['allowed_mime_types'] = array_map('strtolower', $allowedMimes);
6✔
370

371
        return $this;
6✔
372
    }
373

374
    /**
375
     * Prefer the MIME type provided by the client, if any, over the inferred MIME type.
376
     * Depending on the source, this may not be accurate.
377
     * @return $this
378
     */
379
    public function preferClientMimeType(): self
380
    {
NEW
381
        $this->config['prefer_client_mime_type'] = true;
×
382

NEW
383
        return $this;
×
384
    }
385

386
    /**
387
     * Prefer the MIME type inferred by the contents of the file, if available,
388
     * over the MIME type provided by the client.
389
     * @return $this
390
     */
391
    public function preferInferredMimeType(): self
392
    {
NEW
393
        $this->config['prefer_client_mime_type'] = false;
×
394

NEW
395
        return $this;
×
396
    }
397

398
    /**
399
     * Set a list of file extensions that the source file must be restricted to.
400
     * @param string[] $allowedExtensions
401
     * @return $this
402
     */
403
    public function setAllowedExtensions(array $allowedExtensions): self
404
    {
405
        $this->config['allowed_extensions'] = array_map('strtolower', $allowedExtensions);
6✔
406

407
        return $this;
6✔
408
    }
409

410
    /**
411
     * Set a list of aggregate types that the source file must be restricted to.
412
     * @param string[] $allowedTypes
413
     * @return $this
414
     */
415
    public function setAllowedAggregateTypes(array $allowedTypes): self
416
    {
417
        $this->config['allowed_aggregate_types'] = $allowedTypes;
18✔
418

419
        return $this;
18✔
420
    }
421

422
    /**
423
     * Verify the MD5 hash of the file contents matches an expected value.
424
     * The upload process will throw an InvalidHashException if the hash of the
425
     * uploaded file does not match the provided value.
426
     * @param string|null $expectedHash set to null to disable hash validation
427
     * @param string $algo any hashing algorithm supported by PHP's hash() function
428
     * @return $this
429
     */
430
    public function validateHash(?string $expectedHash, string $algo = 'md5'): self
431
    {
432
        $this->expectedHashes[$algo] = $expectedHash;
12✔
433
        return $this;
12✔
434
    }
435

436
    /**
437
     * Make the resulting file public (default behaviour)
438
     * @return $this
439
     */
440
    public function makePublic(): self
441
    {
442
        $this->visibility = Filesystem::VISIBILITY_PUBLIC;
6✔
443
        return $this;
6✔
444
    }
445

446
    /**
447
     * Make the resulting file private
448
     * @return $this
449
     */
450
    public function makePrivate(): self
451
    {
452
        $this->visibility = Filesystem::VISIBILITY_PRIVATE;
6✔
453
        return $this;
6✔
454
    }
455

456
    public function getVisibility(): string
457
    {
458
        if ($this->visibility) {
114✔
459
            return $this->visibility;
6✔
460
        }
461

462
        return config(
114✔
463
            'filesystems.disks.'.$this->disk.'.visibility',
114✔
464
            Filesystem::VISIBILITY_PUBLIC
114✔
465
        );
114✔
466
    }
467

468
    /**
469
     * Apply an image manipulation to the uploaded image.
470
     *
471
     * This will modify the image before saving it to disk.
472
     * The original image will not be preserved.
473
     *
474
     * Note this will manipulate the image as part of the upload process, which may be slow.
475
     * @param string|ImageManipulation $imageManipulation Either a defined ImageManipulation variant name
476
     *   or an ImageManipulation instance
477
     * @return $this
478
     */
479
    public function applyImageManipulation($imageManipulation): self
480
    {
481
        if (is_string($imageManipulation)) {
12✔
482
            $imageManipulation = $this->imageManipulator->getVariantDefinition($imageManipulation);
6✔
483
        }
484
        $this->config['image_manipulation'] = $imageManipulation;
12✔
485
        return $this;
12✔
486
    }
487

488
    /**
489
     * Additional options to pass to the filesystem when uploading
490
     * @param array $options
491
     * @return $this
492
     */
493
    public function withOptions(array $options): self
494
    {
495
        $this->options = $options;
6✔
496
        return $this;
6✔
497
    }
498

499
    /**
500
     * Determine the aggregate type of the file based on the MIME type and the extension.
501
     * @param  string $mimeType
502
     * @param  string $extension
503
     * @return string
504
     * @throws FileNotSupportedException If the file type is not recognized
505
     * @throws FileNotSupportedException If the file type is restricted
506
     * @throws FileNotSupportedException If the aggregate type is restricted
507
     */
508
    public function inferAggregateType(string $mimeType, string $extension): string
509
    {
510
        $mimeType = strtolower($mimeType);
186✔
511
        $extension = strtolower($extension);
186✔
512
        $allowedTypes = $this->config['allowed_aggregate_types'] ?? [];
186✔
513
        $typesForMime = $this->possibleAggregateTypesForMimeType($mimeType);
186✔
514
        $typesForExtension = $this->possibleAggregateTypesForExtension($extension);
186✔
515

516
        if (count($allowedTypes)) {
186✔
517
            $intersection = array_intersect($typesForMime, $typesForExtension, $allowedTypes);
12✔
518
        } else {
519
            $intersection = array_intersect($typesForMime, $typesForExtension);
180✔
520
        }
521

522
        if (count($intersection)) {
186✔
523
            $type = $intersection[0];
165✔
524
        } elseif (empty($typesForMime) && empty($typesForExtension)) {
33✔
525
            if (!$this->config['allow_unrecognized_types'] ?? false) {
18✔
526
                throw FileNotSupportedException::unrecognizedFileType($mimeType, $extension);
12✔
527
            }
528
            $type = Media::TYPE_OTHER;
12✔
529
        } else {
530
            if ($this->config['strict_type_checking'] ?? false) {
21✔
531
                throw FileNotSupportedException::strictTypeMismatch($mimeType, $extension);
6✔
532
            }
533
            $merged = array_merge($typesForMime, $typesForExtension);
15✔
534
            $type = reset($merged);
15✔
535
        }
536

537
        if (count($allowedTypes) && !in_array($type, $allowedTypes)) {
174✔
538
            throw FileNotSupportedException::aggregateTypeRestricted($type, $allowedTypes);
6✔
539
        }
540

541
        return $type;
174✔
542
    }
543

544
    /**
545
     * Determine the aggregate type of the file based on the MIME type.
546
     * @param  string $mime
547
     * @return string[]
548
     */
549
    public function possibleAggregateTypesForMimeType(string $mime): array
550
    {
551
        $types = [];
186✔
552
        foreach ($this->config['aggregate_types'] ?? [] as $type => $attributes) {
186✔
553
            if (in_array($mime, $attributes['mime_types'])) {
186✔
554
                $types[] = $type;
171✔
555
            }
556
        }
557

558
        return $types;
186✔
559
    }
560

561
    /**
562
     * Determine the aggregate type of the file based on the extension.
563
     * @param  string $extension
564
     * @return string[]
565
     */
566
    public function possibleAggregateTypesForExtension(string $extension): array
567
    {
568
        $types = [];
186✔
569
        foreach ($this->config['aggregate_types'] ?? [] as $type => $attributes) {
186✔
570
            if (in_array($extension, $attributes['extensions'])) {
186✔
571
                $types[] = $type;
174✔
572
            }
573
        }
574

575
        return $types;
186✔
576
    }
577

578
    /**
579
     * Process the file upload.
580
     *
581
     * Validates the source, then stores the file onto the disk and creates and stores a new Media instance.
582
     *
583
     * @return Media
584
     * @throws ConfigurationException
585
     * @throws FileExistsException
586
     * @throws FileNotFoundException
587
     * @throws FileNotSupportedException
588
     * @throws FileSizeException
589
     * @throws InvalidHashException
590
     */
591
    public function upload(): Media
592
    {
593
        $this->verifyFile();
102✔
594

595
        $model = $this->populateModel($this->makeModel());
96✔
596

597
        $this->manipulateImage($model);
96✔
598

599
        if (is_callable($this->before_save)) {
96✔
600
            call_user_func($this->before_save, $model, $this->source);
12✔
601
        }
602

603
        $this->verifyDestination($model);
96✔
604
        $this->writeToDisk($model);
96✔
605
        $model->save();
96✔
606

607
        return $model;
96✔
608
    }
609

610
    /**
611
     * Process the file upload, overwriting an existing media's file
612
     *
613
     * Uploader will automatically place the file on the same disk as the original media.
614
     *
615
     * @param  Media $media
616
     * @return Media
617
     *
618
     * @throws ConfigurationException
619
     * @throws FileNotFoundException
620
     * @throws FileNotSupportedException
621
     * @throws FileSizeException
622
     * @throws ForbiddenException
623
     * @throws FileExistsException
624
     */
625
    public function replace(Media $media): Media
626
    {
627
        if (!$this->disk) {
12✔
628
            $this->toDisk($media->disk);
6✔
629
        }
630

631
        if (!$this->directory) {
12✔
632
            $this->toDirectory($media->directory);
6✔
633
        }
634

635
        if (!$this->filename) {
12✔
636
            $this->useFilename($media->filename);
6✔
637
        }
638

639
        // Remember original file location.
640
        // We will only delete it if validation passes
641
        $disk = $media->disk;
12✔
642
        $path = $media->getDiskPath();
12✔
643

644
        $model = $this->populateModel($media);
12✔
645

646
        if (is_callable($this->before_save)) {
12✔
647
            call_user_func($this->before_save, $model, $this->source);
×
648
        }
649

650
        $this->verifyDestination($model);
12✔
651
        // Delete original file, if necessary
652
        $this->filesystem->disk($disk)->delete($path);
12✔
653
        $this->writeToDisk($model);
12✔
654

655
        $model->save();
12✔
656

657
        return $model;
12✔
658
    }
659

660
    /**
661
     * Validate input and convert to Media attributes
662
     * @param  Media $model
663
     * @return Media
664
     *
665
     * @throws ConfigurationException
666
     * @throws FileNotFoundException
667
     * @throws FileNotSupportedException
668
     * @throws FileSizeException
669
     */
670
    private function populateModel(Media $model): Media
671
    {
672
        $model->size = $this->verifyFileSize($this->source->size() ?? 0);
108✔
673
        $model->mime_type = $this->verifyMimeType($this->selectMimeType());
108✔
674
        $model->extension = $this->verifyExtension(
108✔
675
            $this->source->extension()
108✔
676
                ?? File::guessExtension($model->mime_type)
108✔
677
        );
108✔
678
        $model->aggregate_type = $this->inferAggregateType($model->mime_type, $model->extension);
108✔
679

680
        $model->disk = $this->disk ?: $this->config['default_disk'];
108✔
681
        $model->directory = $this->directory;
108✔
682
        $model->filename = $this->generateFilename();
108✔
683

684
        if ($this->alt) {
108✔
685
            $model->alt = $this->alt;
12✔
686
        }
687

688
        return $model;
108✔
689
    }
690

691
    /**
692
     * Set the before save callback
693
     * @param callable $callable
694
     * @return $this
695
     */
696
    public function beforeSave(callable $callable): self
697
    {
698
        $this->before_save = $callable;
12✔
699
        return $this;
12✔
700
    }
701

702
    /**
703
     * Create a `Media` record for a file already on a disk.
704
     *
705
     * @param  string $disk
706
     * @param  string $path Path to file, relative to disk root
707
     *
708
     * @return Media
709
     * @throws ConfigurationException
710
     * @throws FileNotFoundException
711
     * @throws FileNotSupportedException
712
     * @throws FileSizeException
713
     * @throws ForbiddenException
714
     */
715
    public function importPath(string $disk, string $path): Media
716
    {
717
        $directory = File::cleanDirname($path);
36✔
718
        $filename = pathinfo($path, PATHINFO_FILENAME);
36✔
719
        $extension = pathinfo($path, PATHINFO_EXTENSION);
36✔
720

721
        return $this->import($disk, $directory, $filename, $extension);
36✔
722
    }
723

724
    /**
725
     * Create a `Media` record for a file already on a disk.
726
     *
727
     * @param  string $disk
728
     * @param  string $directory
729
     * @param  string $filename
730
     * @param  string $extension
731
     *
732
     * @return Media
733
     * @throws ConfigurationException
734
     * @throws FileNotFoundException If the file does not exist
735
     * @throws FileNotSupportedException
736
     * @throws FileSizeException
737
     * @throws ForbiddenException
738
     */
739
    public function import(string $disk, string $directory, string $filename, string $extension): Media
740
    {
741
        $disk = $this->verifyDisk($disk);
42✔
742
        $storage = $this->filesystem->disk($disk);
42✔
743

744
        $model = $this->makeModel();
42✔
745
        $model->disk = $disk;
42✔
746
        $model->directory = $directory;
42✔
747
        $model->filename = $filename;
42✔
748
        $model->extension = $this->verifyExtension($extension, false);
42✔
749

750
        if (!$storage->exists($model->getDiskPath())) {
42✔
751
            throw FileNotFoundException::fileNotFound($model->getDiskPath());
6✔
752
        }
753

754
        $model->mime_type = $this->verifyMimeType(
36✔
755
            $this->inferMimeType($storage, $model->getDiskPath())
36✔
756
        );
36✔
757
        $model->aggregate_type = $this->inferAggregateType($model->mime_type, $model->extension);
36✔
758
        $model->size = $this->verifyFileSize($storage->size($model->getDiskPath()));
30✔
759

760
        if ($this->visibility) {
30✔
NEW
761
            $storage->setVisibility($model->getDiskPath(), $this->visibility);
×
762
        }
763

764
        if ($this->alt) {
30✔
NEW
765
            $model->alt = $this->alt;
×
766
        }
767

768
        if (is_callable($this->before_save)) {
30✔
769
            call_user_func($this->before_save, $model, $this->source);
×
770
        }
771

772
        $model->save();
30✔
773

774
        return $model;
30✔
775
    }
776

777
    /**
778
     * Reanalyze a media record's file and adjust the aggregate type and size, if necessary.
779
     *
780
     * @param  Media $media
781
     *
782
     * @return bool Whether the model was modified
783
     * @throws FileNotSupportedException
784
     * @throws FileSizeException
785
     */
786
    public function update(Media $media):  bool
787
    {
788
        $storage = $this->filesystem->disk($media->disk);
12✔
789

790
        $media->size = $this->verifyFileSize($storage->size($media->getDiskPath()));
12✔
791
        $media->mime_type = $this->verifyMimeType(
12✔
792
            $this->inferMimeType($storage, $media->getDiskPath())
12✔
793
        );
12✔
794
        $media->aggregate_type = $this->inferAggregateType($media->mime_type, $media->extension);
12✔
795

796
        if ($this->alt) {
12✔
NEW
797
            $media->alt = $this->alt;
×
798
        }
799

800
        if ($dirty = $media->isDirty()) {
12✔
801
            $media->save();
12✔
802
        }
803

804
        return $dirty;
12✔
805
    }
806

807
    /**
808
     * Verify if file is valid
809
     * @throws ConfigurationException If no source is provided
810
     * @throws FileNotFoundException If the source is invalid
811
     * @throws FileSizeException If the file is too large
812
     * @throws FileNotSupportedException If the mime type is not allowed
813
     * @throws FileNotSupportedException If the file extension is not allowed
814
     * @return void
815
     */
816
    public function verifyFile(): void
817
    {
818
        $this->verifySource();
102✔
819
        $this->verifyFileSize($this->source->size() ?? 0);
102✔
820
        $mimeType = $this->verifyMimeType(
102✔
821
            $this->selectMimeType()
102✔
822
        );
102✔
823
        $this->verifyExtension(
102✔
824
            $this->source->extension() ?? File::guessExtension($mimeType)
102✔
825
        );
102✔
826

827
        $this->verifyHashes();
102✔
828
    }
829

830
    /**
831
     * Generate an instance of the `Media` class.
832
     * @return Media
833
     */
834
    private function makeModel(): Media
835
    {
836
        $class = $this->config['model'] ?? Media::class;
144✔
837

838
        return new $class;
144✔
839
    }
840

841
    /**
842
     * Ensure that the provided filesystem disk name exists and is allowed.
843
     * @param  string $disk
844
     * @return string
845
     * @throws ConfigurationException If the disk does not exist
846
     * @throws ForbiddenException If the disk is not included in the `allowed_disks` config.
847
     */
848
    private function verifyDisk(string $disk): string
849
    {
850
        if (!array_key_exists($disk, config('filesystems.disks', []))) {
168✔
851
            throw ConfigurationException::diskNotFound($disk);
6✔
852
        }
853

854
        if (!in_array($disk, $this->config['allowed_disks'] ?? [])) {
162✔
855
            throw ForbiddenException::diskNotAllowed($disk);
6✔
856
        }
857

858
        return $disk;
156✔
859
    }
860

861
    /**
862
     * Ensure that a valid source has been provided.
863
     * @return void
864
     * @throws ConfigurationException If no source is provided
865
     * @throws FileNotFoundException If the source is invalid
866
     */
867
    private function verifySource(): void
868
    {
869
        if (empty($this->source)) {
108✔
870
            throw ConfigurationException::noSourceProvided();
6✔
871
        }
872
    }
873

874
    private function inferMimeType(Filesystem $filesystem, string $path): string
875
    {
876
        $mimeType = null;
48✔
877
        try {
878
            if (method_exists($filesystem, 'mimeType')) {
48✔
879
                $mimeType = $filesystem->mimeType($path);
48✔
880
            }
UNCOV
881
        } catch (UnableToRetrieveMetadata $e) {
×
882
            // previous versions of flysystem would default to octet-stream when
883
            // the file was unrecognized. Maintain the behaviour for now
884
            return 'application/octet-stream';
×
885
        }
886
        return $mimeType ?: 'application/octet-stream';
48✔
887
    }
888

889
    private function selectMimeType(): string
890
    {
891
        if ($this->config['prefer_client_mime_type'] ?? false) {
114✔
NEW
892
            return $this->source->clientMimeType() ?? $this->source->mimeType();
×
893
        }
894
        return $this->source->mimeType();
114✔
895
    }
896

897
    /**
898
     * Ensure that the file's mime type is allowed.
899
     * @param  string $mimeType
900
     * @return string
901
     * @throws FileNotSupportedException If the mime type is not allowed
902
     */
903
    private function verifyMimeType(string $mimeType): string
904
    {
905
        $mimeType = strtolower($mimeType);
168✔
906
        $allowed = $this->config['allowed_mime_types'] ?? [];
168✔
907
        if (!empty($allowed) && !in_array($mimeType, $allowed)) {
168✔
908
            throw FileNotSupportedException::mimeRestricted($mimeType, $allowed);
6✔
909
        }
910

911
        return $mimeType;
168✔
912
    }
913

914
    /**
915
     * Ensure that the file's extension is allowed.
916
     * @param  string $extension
917
     * @param  bool $toLower
918
     * @return string
919
     * @throws FileNotSupportedException If the file extension is not allowed
920
     */
921
    private function verifyExtension(string $extension, bool $toLower = true): string
922
    {
923
        $extensionLower = strtolower($extension);
162✔
924
        $allowed = $this->config['allowed_extensions'] ?? [];
162✔
925
        if (!empty($allowed) && !in_array($extensionLower, $allowed)) {
162✔
926
            throw FileNotSupportedException::extensionRestricted($extensionLower, $allowed);
6✔
927
        }
928

929
        return $toLower ? $extensionLower : $extension;
162✔
930
    }
931

932
    /**
933
     * Verify that the file being uploaded is not larger than the maximum.
934
     * @param  int $size
935
     * @return int
936
     * @throws FileSizeException If the file is too large
937
     */
938
    private function verifyFileSize(int $size): int
939
    {
940
        $max = $this->config['max_size'] ?? 0;
168✔
941
        if ($max > 0 && $size > $max) {
168✔
942
            throw FileSizeException::fileIsTooBig($size, $max);
6✔
943
        }
944

945
        return $size;
168✔
946
    }
947

948
    private function verifyHashes(): void
949
    {
950
        foreach ($this->expectedHashes as $algo => $expectedHash) {
102✔
951
            if ($expectedHash === null) {
12✔
NEW
952
                return;
×
953
            }
954

955
            $actualHash = $this->source->hash($algo);
12✔
956
            if ($actualHash !== $expectedHash) {
12✔
957
                throw InvalidHashException::hashMismatch(
6✔
958
                    $algo,
6✔
959
                    $expectedHash,
6✔
960
                    $actualHash
6✔
961
                );
6✔
962
            }
963
        }
964
    }
965

966
    /**
967
     * Verify that the intended destination is available and handle any duplications.
968
     * @param  Media $model
969
     * @return void
970
     *
971
     * @throws FileExistsException
972
     */
973
    private function verifyDestination(Media $model): void
974
    {
975
        $storage = $this->filesystem->disk($model->disk);
108✔
976

977
        if ($storage->exists($model->getDiskPath())) {
108✔
978
            $this->handleDuplicate($model);
36✔
979
        }
980
    }
981

982
    /**
983
     * Decide what to do about duplicated files.
984
     *
985
     * @param  Media $model
986
     * @return Media
987
     * @throws FileExistsException If directory is not writable or file already exists at the destination and on_duplicate is set to 'error'
988
     */
989
    private function handleDuplicate(Media $model): Media
990
    {
991
        switch ($this->config['on_duplicate'] ?? MediaUploader::ON_DUPLICATE_INCREMENT) {
60✔
992
            case static::ON_DUPLICATE_ERROR:
60✔
993
                throw FileExistsException::fileExists($model->getDiskPath());
6✔
994
            case static::ON_DUPLICATE_REPLACE:
54✔
995
                $this->deleteExistingMedia($model);
12✔
996
                break;
12✔
997
            case static::ON_DUPLICATE_REPLACE_WITH_VARIANTS:
42✔
998
                $this->deleteExistingMedia($model, true);
6✔
999
                break;
6✔
1000
            case static::ON_DUPLICATE_UPDATE:
36✔
1001
                $original = $model->newQuery()
12✔
1002
                   ->where('disk', $model->disk)
12✔
1003
                   ->where('directory', $model->directory)
12✔
1004
                   ->where('filename', $model->filename)
12✔
1005
                   ->where('extension', $model->extension)
12✔
1006
                   ->first();
12✔
1007

1008
                if ($original) {
12✔
1009
                    $model->{$model->getKeyName()} = $original->getKey();
6✔
1010
                    $model->exists = true;
6✔
1011
                }
1012
                break;
12✔
1013
            case static::ON_DUPLICATE_INCREMENT:
24✔
1014
            default:
1015
                $model->filename = $this->generateUniqueFilename($model);
24✔
1016
        }
1017
        return $model;
54✔
1018
    }
1019

1020
    /**
1021
     * Delete the media that previously existed at a destination.
1022
     * @param  Media $model
1023
     * @param  bool $withVariants
1024
     * @return void
1025
     */
1026
    private function deleteExistingMedia(Media $model, bool $withVariants = false): void
1027
    {
1028
        $original = $model->newQuery()
18✔
1029
            ->where('disk', $model->disk)
18✔
1030
            ->where('directory', $model->directory)
18✔
1031
            ->where('filename', $model->filename)
18✔
1032
            ->where('extension', $model->extension)
18✔
1033
            ->first();
18✔
1034
        if ($original) {
18✔
1035
            $models = $withVariants ? $original->getAllVariantsAndSelf() : collect([$original]);
18✔
1036
            $models->each(
18✔
1037
                function (Media $variant) {
18✔
1038
                    $variant->delete();
18✔
1039
                    $this->deleteExistingFile($variant);
18✔
1040
                }
18✔
1041
            );
18✔
1042
        }
1043
    }
1044

1045
    /**
1046
     * Delete the file on disk.
1047
     * @param  Media $model
1048
     * @return void
1049
     */
1050
    private function deleteExistingFile(Media $model): void
1051
    {
1052
        $this->filesystem->disk($model->disk)->delete($model->getDiskPath());
18✔
1053
    }
1054

1055
    /**
1056
     * Increment model's filename until one is found that doesn't already exist.
1057
     * @param  Media $model
1058
     * @return string
1059
     */
1060
    private function generateUniqueFilename(Media $model): string
1061
    {
1062
        $storage = $this->filesystem->disk($model->disk);
24✔
1063
        $counter = 0;
24✔
1064
        do {
1065
            $filename = "{$model->filename}";
24✔
1066
            if ($counter > 0) {
24✔
1067
                $filename .= '-' . $counter;
24✔
1068
            }
1069
            $path = "{$model->directory}/{$filename}.{$model->extension}";
24✔
1070
            ++$counter;
24✔
1071
        } while ($storage->exists($path));
24✔
1072

1073
        return $filename;
24✔
1074
    }
1075

1076
    /**
1077
     * Generate the model's filename.
1078
     * @return string
1079
     */
1080
    private function generateFilename(): string
1081
    {
1082
        if ($this->filename) {
108✔
1083
            return $this->filename;
78✔
1084
        }
1085

1086
        if ($this->hashFilenameAlgo) {
30✔
1087
            return $this->source->hash($this->hashFilenameAlgo);
12✔
1088
        }
1089

1090
        $filename = $this->source->filename();
18✔
1091

1092
        if ($filename === null) {
18✔
NEW
1093
            ConfigurationException::cannotInferFilename();
×
1094
        }
1095

1096
        return File::sanitizeFileName($filename);
18✔
1097
    }
1098

1099
    private function writeToDisk(Media $model): void
1100
    {
1101
        $this->filesystem->disk($model->disk)
108✔
1102
            ->put(
108✔
1103
                $model->getDiskPath(),
108✔
1104
                $this->source->getStream(),
108✔
1105
                $this->getOptions()
108✔
1106
            );
108✔
1107
    }
1108

1109
    public function getOptions(): array
1110
    {
1111
        $options = $this->options;
114✔
1112
        if (!isset($options['visibility'])) {
114✔
1113
            $options['visibility'] = $this->getVisibility();
114✔
1114
        }
1115
        return $options;
114✔
1116
    }
1117

1118
    /**
1119
     * @param Media $model
1120
     * @return void
1121
     * @throws Exceptions\ImageManipulationException
1122
     */
1123
    public function manipulateImage(Media $model): void
1124
    {
1125
        if (empty($this->config['image_manipulation'])
96✔
1126
            || $model->aggregate_type !== Media::TYPE_IMAGE
96✔
1127
        ) {
1128
            return;
90✔
1129
        }
1130
        $manipulation = $this->config['image_manipulation'];
6✔
1131
        $this->source = $this->imageManipulator->manipulateUpload(
6✔
1132
            $model,
6✔
1133
            $this->source,
6✔
1134
            $manipulation
6✔
1135
        );
6✔
1136
    }
1137
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc