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

plank / laravel-mediable / 29220047496

13 Jul 2026 02:41AM UTC coverage: 93.691% (-0.6%) from 94.27%
29220047496

Pull #391

github

frasmage
style fixes
Pull Request #391: 7.x

187 of 211 new or added lines in 8 files covered. (88.63%)

1 existing line in 1 file now uncovered.

1589 of 1696 relevant lines covered (93.69%)

162.66 hits per line

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

93.68
/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 Illuminate\Support\Arr;
9
use League\Flysystem\UnableToRetrieveMetadata;
10
use Plank\Mediable\Enum\OnDuplicateBehaviour;
11
use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException;
12
use Plank\Mediable\Exceptions\MediaUpload\FileExistsException;
13
use Plank\Mediable\Exceptions\MediaUpload\FileNotFoundException;
14
use Plank\Mediable\Exceptions\MediaUpload\FileNotSupportedException;
15
use Plank\Mediable\Exceptions\MediaUpload\FileSizeException;
16
use Plank\Mediable\Exceptions\MediaUpload\ForbiddenException;
17
use Plank\Mediable\Exceptions\MediaUpload\InvalidHashException;
18
use Plank\Mediable\FileSanitizers\SanitizerInterface;
19
use Plank\Mediable\Helpers\File;
20
use Plank\Mediable\SourceAdapters\RawContentAdapter;
21
use Plank\Mediable\SourceAdapters\SourceAdapterFactory;
22
use Plank\Mediable\SourceAdapters\SourceAdapterInterface;
23
use Plank\Mediable\SourceAdapters\StreamAdapter;
24

25
/**
26
 * Media Uploader.
27
 *
28
 * Validates files, uploads them to disk and generates Media
29
 */
30
class MediaUploader
31
{
32
    private FileSystemManager $filesystem;
33

34
    private SourceAdapterFactory $factory;
35

36
    private ImageManipulator $imageManipulator;
37

38
    private MediaUploaderConfiguration $config;
39

40
    private SourceAdapterInterface $source;
41

42
    /**
43
     * Constructor.
44
     * @param FilesystemManager $filesystem
45
     * @param SourceAdapterFactory $factory
46
     * @param ImageManipulator $imageManipulator
47
     * @param MediaUploaderConfiguration $configuration
48
     */
49
    public function __construct(
50
        FileSystemManager $filesystem,
51
        SourceAdapterFactory $factory,
52
        ImageManipulator $imageManipulator,
53
        MediaUploaderConfiguration $configuration
54
    ) {
55
        $this->filesystem = $filesystem;
912✔
56
        $this->factory = $factory;
912✔
57
        $this->imageManipulator = $imageManipulator;
912✔
58
        $this->config = $configuration;
912✔
59
    }
60

61
    /**
62
     * Set the source for the file.
63
     *
64
     * @param  mixed $source
65
     *
66
     * @return $this
67
     * @throws ConfigurationException
68
     */
69
    public function fromSource(mixed $source): self
70
    {
71
        $this->source = $this->factory->create($source);
120✔
72

73
        return $this;
120✔
74
    }
75

76
    /**
77
     * Set the source for the string data.
78
     * @param  string $source
79
     * @return $this
80
     */
81
    public function fromString(string $source): self
82
    {
83
        $this->source = new RawContentAdapter($source);
12✔
84

85
        return $this;
12✔
86
    }
87

88
    /**
89
     * Set the filesystem disk and relative directory where the file will be saved.
90
     *
91
     * @param  string $disk
92
     * @param  string $directory
93
     *
94
     * @return $this
95
     * @throws ConfigurationException
96
     * @throws ForbiddenException
97
     */
98
    public function toDestination(string $disk, string $directory): self
99
    {
100
        return $this->toDisk($disk)->toDirectory($directory);
114✔
101
    }
102

103
    /**
104
     * Set the filesystem disk on which the file will be saved.
105
     *
106
     * @param string $disk
107
     *
108
     * @return $this
109
     * @throws ConfigurationException
110
     * @throws ForbiddenException
111
     */
112
    public function toDisk(string $disk): self
113
    {
114
        $this->config->destinationDisk = $this->verifyDisk($disk);
132✔
115

116
        return $this;
120✔
117
    }
118

119
    /**
120
     * Set the directory relative to the filesystem disk at which the file will be saved.
121
     * @param string $directory
122
     * @return $this
123
     */
124
    public function toDirectory(string $directory): self
125
    {
126
        $this->config->destinationDirectory = File::sanitizePath($directory);
120✔
127

128
        return $this;
120✔
129
    }
130

131
    /**
132
     * Specify the filename to copy to the file to.
133
     * @param string $filename
134
     * @return $this
135
     */
136
    public function useFilename(string $filename): self
137
    {
138
        $this->config->destinationFilename = File::sanitizeFilename(
90✔
139
            $filename,
90✔
140
            null,
90✔
141
            $this->config->forbiddenExtensions
90✔
142
        );
90✔
143
        $this->config->hashFilenameAlgorithm = null;
90✔
144

145
        return $this;
90✔
146
    }
147

148
    public function withAltAttribute(string $alt): self
149
    {
150
        $this->config->fileAlternativeText = $alt;
12✔
151
        return $this;
12✔
152
    }
153

154
    /**
155
     * Indicates to the uploader to generate a filename using the file's MD5 hash.
156
     * @param string $algo any hashing algorithm supported by PHP's hash() function
157
     * @return $this
158
     */
159
    public function useHashForFilename(string $algo = 'md5'): self
160
    {
161
        $this->config->hashFilenameAlgorithm = $algo;
12✔
162
        $this->config->destinationFilename = null;
12✔
163

164
        return $this;
12✔
165
    }
166

167
    /**
168
     * Restore the default behaviour of using the source file's filename.
169
     * @return $this
170
     */
171
    public function useOriginalFilename(): self
172
    {
NEW
173
        $this->config->destinationFilename = null;
×
NEW
174
        $this->config->hashFilenameAlgorithm = null;
×
175

176
        return $this;
×
177
    }
178

179
    /**
180
     * Change the class to use for generated Media.
181
     * @param string $class
182
     * @return $this
183
     * @throws ConfigurationException if $class does not extend Plank\Mediable\Media
184
     */
185
    public function setModelClass(string $class): self
186
    {
187
        if (!is_subclass_of($class, Media::class)) {
12✔
188
            throw ConfigurationException::cannotSetModel($class);
6✔
189
        }
190
        $this->config->modelClass = $class;
6✔
191

192
        return $this;
6✔
193
    }
194

195
    /**
196
     * Change the maximum allowed file size.
197
     * @param int $size
198
     * @return $this
199
     */
200
    public function setMaximumSize(int $size): self
201
    {
202
        $this->config->maxUploadSize = $size;
12✔
203

204
        return $this;
12✔
205
    }
206

207
    /**
208
     * Change the behaviour for when a file already exists at the destination.
209
     * @param OnDuplicateBehaviour $behavior
210
     * @return $this
211
     */
212
    public function setOnDuplicateBehavior(OnDuplicateBehaviour $behavior): self
213
    {
214
        $this->config->onDuplicate = $behavior;
48✔
215

216
        return $this;
48✔
217
    }
218

219
    /**
220
     * Get current behavior when duplicate file is uploaded.
221
     *
222
     * @return OnDuplicateBehaviour
223
     */
224
    public function getOnDuplicateBehavior(): OnDuplicateBehaviour
225
    {
226
        return $this->config->onDuplicate;
6✔
227
    }
228

229
    /**
230
     * Throw an exception when file already exists at the destination.
231
     *
232
     * @return $this
233
     */
234
    public function onDuplicateError(): self
235
    {
236
        return $this->setOnDuplicateBehavior(OnDuplicateBehaviour::Error);
12✔
237
    }
238

239
    /**
240
     * Append incremented counter to file name when file already exists at destination.
241
     *
242
     * @return $this
243
     */
244
    public function onDuplicateIncrement(): self
245
    {
246
        return $this->setOnDuplicateBehavior(OnDuplicateBehaviour::Increment);
12✔
247
    }
248

249
    /**
250
     * Overwrite existing Media when file already exists at destination.
251
     *
252
     * This will delete the old media record and create a new one, detaching any existing associations.
253
     *
254
     * @return $this
255
     */
256
    public function onDuplicateReplace(): self
257
    {
258
        return $this->setOnDuplicateBehavior(OnDuplicateBehaviour::Replace);
18✔
259
    }
260

261
    /**
262
     * Overwrite existing Media when file already exists at destination and delete any variants of the original record.
263
     *
264
     * This will delete the old media record and create a new one, detaching any existing associations.
265
     *
266
     * This will also delete any existing
267
     *
268
     * @return $this
269
     */
270
    public function onDuplicateReplaceWithVariants(): self
271
    {
272
        return $this->setOnDuplicateBehavior(OnDuplicateBehaviour::ReplaceWithVariants);
12✔
273
    }
274

275
    /**
276
     * Overwrite existing files and update the existing media record.
277
     *
278
     * This will retain any existing associations.
279
     *
280
     * @return $this
281
     */
282
    public function onDuplicateUpdate(): self
283
    {
284
        return $this->setOnDuplicateBehavior(OnDuplicateBehaviour::Update);
18✔
285
    }
286

287
    /**
288
     * Change whether both the MIME type and extensions must match the same aggregate type.
289
     * @param bool $strict
290
     * @return $this
291
     */
292
    public function setStrictTypeChecking(bool $strict): self
293
    {
294
        $this->config->strictTypeChecking = $strict;
6✔
295

296
        return $this;
6✔
297
    }
298

299
    /**
300
     * Change whether files not matching any aggregate types are allowed.
301
     * @param bool $allow
302
     * @return $this
303
     */
304
    public function setAllowUnrecognizedTypes(bool $allow): self
305
    {
306
        $this->config->allowUnrecognizedTypes = $allow;
18✔
307

308
        return $this;
18✔
309
    }
310

311
    /**
312
     * Add or update the definition of a aggregate type.
313
     * @param string $type the name of the type
314
     * @param string[] $mimeTypes list of MIME types recognized
315
     * @param string[] $extensions list of file extensions recognized
316
     * @return $this
317
     */
318
    public function setTypeDefinition(string $type, array $mimeTypes, array $extensions): self
319
    {
320
        $this->config->definedAggregateTypes[$type] = [
24✔
321
            'mime_types' => array_map('strtolower', $mimeTypes),
24✔
322
            'extensions' => array_map('strtolower', $extensions),
24✔
323
        ];
24✔
324

325
        return $this;
24✔
326
    }
327

328
    /**
329
     * Set a list of MIME types that the source file must be restricted to.
330
     * @param string[] $allowedMimes
331
     * @return $this
332
     */
333
    public function setAllowedMimeTypes(array $allowedMimes): self
334
    {
335
        $this->config->allowedMimeTypes = array_map('strtolower', $allowedMimes);
6✔
336

337
        return $this;
6✔
338
    }
339

340
    public function setForbiddenMimeTypes(array $forbiddenMimes): self
341
    {
342
        $this->config->forbiddenMimeTypes = array_map('strtolower', $forbiddenMimes);
12✔
343

344
        return $this;
12✔
345
    }
346

347
    /**
348
     * Prefer the MIME type provided by the client, if any, over the inferred MIME type.
349
     * Depending on the source, this may not be accurate.
350
     * @return $this
351
     */
352
    public function preferClientMimeType(): self
353
    {
NEW
354
        $this->config->preferClientMimeType = true;
×
355

356
        return $this;
×
357
    }
358

359
    /**
360
     * Prefer the MIME type inferred by the contents of the file, if available,
361
     * over the MIME type provided by the client.
362
     * @return $this
363
     */
364
    public function preferInferredMimeType(): self
365
    {
NEW
366
        $this->config->preferClientMimeType = false;
×
367

368
        return $this;
×
369
    }
370

371
    /**
372
     * Set a list of file extensions that the source file must be restricted to.
373
     * @param string[] $allowedExtensions
374
     * @return $this
375
     */
376
    public function setAllowedExtensions(array $allowedExtensions): self
377
    {
378
        $this->config->allowedExtensions = array_map('strtolower', $allowedExtensions);
6✔
379

380
        return $this;
6✔
381
    }
382

383
    public function setForbiddenExtensions(array $forbiddenExtensions): self
384
    {
NEW
385
        $this->config->forbiddenExtensions = array_map('strtolower', $forbiddenExtensions);
×
386

UNCOV
387
        return $this;
×
388
    }
389

390
    /**
391
     * Set a list of aggregate types that the source file must be restricted to.
392
     * @param string[] $allowedTypes
393
     * @return $this
394
     */
395
    public function setAllowedAggregateTypes(array $allowedTypes): self
396
    {
397
        $this->config->allowedAggregateTypes = $allowedTypes;
18✔
398

399
        return $this;
18✔
400
    }
401

402
    /**
403
     * Verify the MD5 hash of the file contents matches an expected value.
404
     * The upload process will throw an InvalidHashException if the hash of the
405
     * uploaded file does not match the provided value.
406
     * @param string|null $expectedHash set to null to disable hash validation
407
     * @param string $algo any hashing algorithm supported by PHP's hash() function
408
     * @return $this
409
     */
410
    public function validateHash(?string $expectedHash, string $algo = 'md5'): self
411
    {
412
        $this->config->expectedHashes[$algo] = $expectedHash;
12✔
413
        return $this;
12✔
414
    }
415

416
    /**
417
     * Make the resulting file public (default behaviour)
418
     * @return $this
419
     */
420
    public function makePublic(): self
421
    {
422
        $this->config->fileVisibility = Filesystem::VISIBILITY_PUBLIC;
6✔
423
        return $this;
6✔
424
    }
425

426
    /**
427
     * Make the resulting file private
428
     * @return $this
429
     */
430
    public function makePrivate(): self
431
    {
432
        $this->config->fileVisibility = Filesystem::VISIBILITY_PRIVATE;
6✔
433
        return $this;
6✔
434
    }
435

436
    public function getVisibility(): string
437
    {
438
        if ($this->config->fileVisibility) {
120✔
439
            return $this->config->fileVisibility;
6✔
440
        }
441

442
        return config(
120✔
443
            'filesystems.disks.'.$this->config->destinationDisk.'.visibility',
120✔
444
            Filesystem::VISIBILITY_PUBLIC
120✔
445
        );
120✔
446
    }
447

448
    /**
449
     * Apply an image manipulation to the uploaded image.
450
     *
451
     * This will modify the image before saving it to disk.
452
     * The original image will not be preserved.
453
     *
454
     * Note this will manipulate the image as part of the upload process, which may be slow.
455
     * @param string|ImageManipulation $imageManipulation Either a defined ImageManipulation variant name
456
     *   or an ImageManipulation instance
457
     * @return $this
458
     */
459
    public function applyImageManipulation(string|ImageManipulation $imageManipulation): self
460
    {
461
        if (is_string($imageManipulation)) {
12✔
462
            $imageManipulation = $this->imageManipulator->getVariantDefinition($imageManipulation);
6✔
463
        }
464
        $this->config->imageManipulation = $imageManipulation;
12✔
465
        return $this;
12✔
466
    }
467

468
    /**
469
     * Additional options to pass to the filesystem when uploading
470
     * @param array $options
471
     * @return $this
472
     */
473
    public function withOptions(array $options): self
474
    {
475
        $this->config->filesystemOptions = $options;
6✔
476
        return $this;
6✔
477
    }
478

479
    /**
480
     * Determine the aggregate type of the file based on the MIME type and the extension.
481
     * @param  string $mimeType
482
     * @param  string $extension
483
     * @return string
484
     * @throws FileNotSupportedException If the file type is not recognized
485
     * @throws FileNotSupportedException If the file type is restricted
486
     * @throws FileNotSupportedException If the aggregate type is restricted
487
     */
488
    public function inferAggregateType(string $mimeType, string $extension): string
489
    {
490
        $mimeType = strtolower($mimeType);
192✔
491
        $extension = strtolower($extension);
192✔
492
        $allowedTypes = $this->config->allowedAggregateTypes;
192✔
493
        $typesForMime = $this->possibleAggregateTypesForMimeType($mimeType);
192✔
494
        $typesForExtension = $this->possibleAggregateTypesForExtension($extension);
192✔
495

496
        if (count($allowedTypes)) {
192✔
497
            $intersection = array_intersect($typesForMime, $typesForExtension, $allowedTypes);
12✔
498
        } else {
499
            $intersection = array_intersect($typesForMime, $typesForExtension);
186✔
500
        }
501

502
        if (count($intersection)) {
192✔
503
            $type = Arr::first($intersection);
170✔
504
        } elseif (empty($typesForMime) && empty($typesForExtension)) {
34✔
505
            if (!$this->config->allowUnrecognizedTypes) {
18✔
506
                throw FileNotSupportedException::unrecognizedFileType($mimeType, $extension);
12✔
507
            }
508
            $type = Media::TYPE_OTHER;
12✔
509
        } else {
510
            if ($this->config->strictTypeChecking) {
22✔
511
                throw FileNotSupportedException::strictTypeMismatch($mimeType, $extension);
6✔
512
            }
513
            $merged = array_merge($typesForMime, $typesForExtension);
16✔
514
            $type = reset($merged);
16✔
515
        }
516

517
        if (count($allowedTypes) && !in_array($type, $allowedTypes)) {
180✔
518
            throw FileNotSupportedException::aggregateTypeRestricted($type, $allowedTypes);
6✔
519
        }
520

521
        return $type;
180✔
522
    }
523

524
    /**
525
     * Determine the aggregate type of the file based on the MIME type.
526
     * @param  string $mime
527
     * @return string[]
528
     */
529
    public function possibleAggregateTypesForMimeType(string $mime): array
530
    {
531
        $types = [];
192✔
532
        foreach ($this->config->definedAggregateTypes as $type => $attributes) {
192✔
533
            if (in_array($mime, $attributes['mime_types'])) {
192✔
534
                $types[] = $type;
176✔
535
            }
536
        }
537

538
        return $types;
192✔
539
    }
540

541
    /**
542
     * Determine the aggregate type of the file based on the extension.
543
     * @param  string $extension
544
     * @return string[]
545
     */
546
    public function possibleAggregateTypesForExtension(string $extension): array
547
    {
548
        $types = [];
192✔
549
        foreach ($this->config->definedAggregateTypes ?? [] as $type => $attributes) {
192✔
550
            if (in_array($extension, $attributes['extensions'])) {
192✔
551
                $types[] = $type;
180✔
552
            }
553
        }
554

555
        return $types;
192✔
556
    }
557

558
    /**
559
     * Process the file upload.
560
     *
561
     * Validates the source, then stores the file onto the disk and creates and stores a new Media instance.
562
     *
563
     * @return Media
564
     * @throws ConfigurationException
565
     * @throws FileExistsException
566
     * @throws FileNotFoundException
567
     * @throws FileNotSupportedException
568
     * @throws FileSizeException
569
     * @throws InvalidHashException
570
     */
571
    public function upload(): Media
572
    {
573
        $this->verifyFile();
120✔
574

575
        $model = $this->populateModel($this->makeModel());
102✔
576

577
        $this->sanitizeFile($model);
102✔
578
        $this->manipulateImage($model);
102✔
579

580
        if ($this->config->beforeSaveCallback) {
102✔
581
            call_user_func($this->config->beforeSaveCallback, $model, $this->source);
12✔
582
        }
583

584
        $this->verifyDestination($model);
102✔
585
        $this->writeToDisk($model);
102✔
586
        $model->save();
102✔
587

588
        return $model;
102✔
589
    }
590

591
    /**
592
     * Process the file upload, overwriting an existing media's file
593
     *
594
     * Uploader will automatically place the file on the same disk as the original media.
595
     *
596
     * @param  Media $media
597
     * @return Media
598
     *
599
     * @throws ConfigurationException
600
     * @throws FileNotFoundException
601
     * @throws FileNotSupportedException
602
     * @throws FileSizeException
603
     * @throws ForbiddenException
604
     * @throws FileExistsException
605
     */
606
    public function replace(Media $media): Media
607
    {
608
        if (!$this->config->destinationDisk) {
12✔
609
            $this->toDisk($media->disk);
6✔
610
        }
611

612
        if (!$this->config->destinationDirectory) {
12✔
613
            $this->toDirectory($media->directory);
6✔
614
        }
615

616
        if (!$this->config->destinationFilename) {
12✔
617
            $this->useFilename($media->filename);
6✔
618
        }
619

620
        // Remember original file location.
621
        // We will only delete it if validation passes
622
        $disk = $media->disk;
12✔
623
        $path = $media->getDiskPath();
12✔
624

625
        $model = $this->populateModel($media);
12✔
626
        $this->sanitizeFile($model);
12✔
627

628
        if ($this->config->beforeSaveCallback) {
12✔
NEW
629
            call_user_func($this->config->beforeSaveCallback, $model, $this->source);
×
630
        }
631

632

633
        $this->verifyDestination($model);
12✔
634
        // Delete original file, if necessary
635
        $this->filesystem->disk($disk)->delete($path);
12✔
636
        $this->writeToDisk($model);
12✔
637

638
        $model->save();
12✔
639

640
        return $model;
12✔
641
    }
642

643
    /**
644
     * Validate input and convert to Media attributes
645
     * @param  Media $model
646
     * @return Media
647
     *
648
     * @throws ConfigurationException
649
     * @throws FileNotFoundException
650
     * @throws FileNotSupportedException
651
     * @throws FileSizeException
652
     */
653
    private function populateModel(Media $model): Media
654
    {
655
        $model->size = $this->verifyFileSize($this->source->size() ?? 0);
114✔
656
        $model->mime_type = $this->verifyMimeType($this->selectMimeType());
114✔
657
        $model->extension = $this->verifyExtension(
114✔
658
            $this->source->extension()
114✔
659
                ?? File::guessExtension($model->mime_type)
114✔
660
        );
114✔
661
        $model->aggregate_type = $this->inferAggregateType($model->mime_type, $model->extension);
114✔
662

663
        $model->disk = $this->config->destinationDisk ?? $this->config->defaultDisk;
114✔
664
        $model->directory = $this->config->destinationDirectory;
114✔
665
        $model->filename = $this->generateFilename();
114✔
666

667
        if ($this->config->fileAlternativeText) {
114✔
668
            $model->alt = $this->config->fileAlternativeText;
12✔
669
        }
670

671
        return $model;
114✔
672
    }
673

674
    /**
675
     * Set the before save callback
676
     * @param \Closure $callable
677
     * @return $this
678
     */
679
    public function beforeSave(\Closure $callable): self
680
    {
681
        $this->config->beforeSaveCallback = $callable;
12✔
682
        return $this;
12✔
683
    }
684

685
    /**
686
     * Create a `Media` record for a file already on a disk.
687
     *
688
     * @param  string $disk
689
     * @param  string $path Path to file, relative to disk root
690
     *
691
     * @return Media
692
     * @throws ConfigurationException
693
     * @throws FileNotFoundException
694
     * @throws FileNotSupportedException
695
     * @throws FileSizeException
696
     * @throws ForbiddenException
697
     */
698
    public function importPath(string $disk, string $path): Media
699
    {
700
        $directory = File::cleanDirname($path);
36✔
701
        $filename = pathinfo($path, PATHINFO_FILENAME);
36✔
702
        $extension = pathinfo($path, PATHINFO_EXTENSION);
36✔
703

704
        return $this->import($disk, $directory, $filename, $extension);
36✔
705
    }
706

707
    /**
708
     * Create a `Media` record for a file already on a disk.
709
     *
710
     * @param  string $disk
711
     * @param  string $directory
712
     * @param  string $filename
713
     * @param  string $extension
714
     *
715
     * @return Media
716
     * @throws ConfigurationException
717
     * @throws FileNotFoundException If the file does not exist
718
     * @throws FileNotSupportedException
719
     * @throws FileSizeException
720
     * @throws ForbiddenException
721
     */
722
    public function import(string $disk, string $directory, string $filename, string $extension): Media
723
    {
724
        $disk = $this->verifyDisk($disk);
42✔
725
        $storage = $this->filesystem->disk($disk);
42✔
726

727
        $model = $this->makeModel();
42✔
728
        $model->disk = $disk;
42✔
729
        $model->directory = $directory;
42✔
730
        $model->filename = $filename;
42✔
731
        $model->extension = $this->verifyExtension($extension, false);
42✔
732

733
        if (!$storage->exists($model->getDiskPath())) {
42✔
734
            throw FileNotFoundException::fileNotFound($model->getDiskPath());
6✔
735
        }
736

737
        $model->mime_type = $this->verifyMimeType(
36✔
738
            $this->inferMimeType($storage, $model->getDiskPath())
36✔
739
        );
36✔
740
        $model->aggregate_type = $this->inferAggregateType($model->mime_type, $model->extension);
36✔
741
        $model->size = $this->verifyFileSize($storage->size($model->getDiskPath()));
30✔
742

743
        if ($this->config->fileVisibility) {
30✔
NEW
744
            $storage->setVisibility($model->getDiskPath(), $this->config->fileVisibility);
×
745
        }
746

747
        if ($this->config->fileAlternativeText) {
30✔
NEW
748
            $model->alt = $this->config->fileAlternativeText;
×
749
        }
750

751
        if ($this->config->beforeSaveCallback) {
30✔
NEW
752
            call_user_func($this->config->beforeSaveCallback, $model, $this->source);
×
753
        }
754

755
        $model->save();
30✔
756

757
        return $model;
30✔
758
    }
759

760
    /**
761
     * Reanalyze a media record's file and adjust the aggregate type and size, if necessary.
762
     *
763
     * @param  Media $media
764
     *
765
     * @return bool Whether the model was modified
766
     * @throws FileNotSupportedException
767
     * @throws FileSizeException
768
     */
769
    public function update(Media $media):  bool
770
    {
771
        $storage = $this->filesystem->disk($media->disk);
12✔
772

773
        $media->size = $this->verifyFileSize($storage->size($media->getDiskPath()));
12✔
774
        $media->mime_type = $this->verifyMimeType(
12✔
775
            $this->inferMimeType($storage, $media->getDiskPath())
12✔
776
        );
12✔
777
        $media->aggregate_type = $this->inferAggregateType($media->mime_type, $media->extension);
12✔
778

779
        if ($this->config->fileAlternativeText) {
12✔
NEW
780
            $media->alt = $this->config->fileAlternativeText;
×
781
        }
782

783
        if ($dirty = $media->isDirty()) {
12✔
784
            $media->save();
12✔
785
        }
786

787
        return $dirty;
12✔
788
    }
789

790
    /**
791
     * Verify if file is valid
792
     * @throws ConfigurationException If no source is provided
793
     * @throws FileNotFoundException If the source is invalid
794
     * @throws FileSizeException If the file is too large
795
     * @throws FileNotSupportedException If the mime type is not allowed
796
     * @throws FileNotSupportedException If the file extension is not allowed
797
     * @return void
798
     */
799
    public function verifyFile(): void
800
    {
801
        $this->verifySource();
120✔
802
        $this->verifyFileSize($this->source->size() ?? 0);
120✔
803
        $mimeType = $this->verifyMimeType(
120✔
804
            $this->selectMimeType()
120✔
805
        );
120✔
806
        $this->verifyExtension(
108✔
807
            $this->source->extension() ?? File::guessExtension($mimeType)
108✔
808
        );
108✔
809

810
        $this->verifyHashes();
108✔
811
    }
812

813
    /**
814
     * Generate an instance of the `Media` class.
815
     * @return Media
816
     */
817
    private function makeModel(): Media
818
    {
819
        $class = $this->config->modelClass;
150✔
820

821
        return new $class;
150✔
822
    }
823

824
    /**
825
     * Ensure that the provided filesystem disk name exists and is allowed.
826
     * @param  string $disk
827
     * @return string
828
     * @throws ConfigurationException If the disk does not exist
829
     * @throws ForbiddenException If the disk is not included in the `allowed_disks` config.
830
     */
831
    private function verifyDisk(string $disk): string
832
    {
833
        if (!array_key_exists($disk, config('filesystems.disks', []))) {
174✔
834
            throw ConfigurationException::diskNotFound($disk);
6✔
835
        }
836

837
        if (!in_array($disk, $this->config->allowedDisks)) {
168✔
838
            throw ForbiddenException::diskNotAllowed($disk);
6✔
839
        }
840

841
        return $disk;
162✔
842
    }
843

844
    /**
845
     * Ensure that a valid source has been provided.
846
     * @return void
847
     * @throws ConfigurationException If no source is provided
848
     */
849
    private function verifySource(): void
850
    {
851
        if (empty($this->source)) {
126✔
852
            throw ConfigurationException::noSourceProvided();
6✔
853
        }
854
    }
855

856
    private function inferMimeType(Filesystem $filesystem, string $path): string
857
    {
858
        $mimeType = null;
48✔
859
        try {
860
            if (method_exists($filesystem, 'mimeType')) {
48✔
861
                $mimeType = $filesystem->mimeType($path);
48✔
862
            }
863
        } catch (UnableToRetrieveMetadata $e) {
×
864
            // previous versions of flysystem would default to octet-stream when
865
            // the file was unrecognized. Maintain the behaviour for now
866
            return 'application/octet-stream';
×
867
        }
868
        return $mimeType ?: 'application/octet-stream';
48✔
869
    }
870

871
    private function selectMimeType(): string
872
    {
873
        if ($this->config->preferClientMimeType) {
132✔
874
            return $this->source->clientMimeType() ?? $this->source->mimeType();
×
875
        }
876
        return $this->source->mimeType();
132✔
877
    }
878

879
    /**
880
     * Ensure that the file's mime type is allowed.
881
     * @param  string $mimeType
882
     * @return string
883
     * @throws FileNotSupportedException If the mime type is not allowed
884
     */
885
    private function verifyMimeType(string $mimeType): string
886
    {
887
        $mimeType = strtolower($mimeType);
186✔
888
        $allowed = $this->config->allowedMimeTypes;
186✔
889
        $forbidden = $this->config->forbiddenMimeTypes;
186✔
890
        $actuallyAllowed = array_diff($allowed, $forbidden);
186✔
891
        if (!empty($allowed) && !in_array($mimeType, $actuallyAllowed)) {
186✔
892
            throw FileNotSupportedException::mimeRestricted($mimeType, $actuallyAllowed);
6✔
893
        }
894
        if (empty($allowed) && in_array($mimeType, $forbidden)) {
186✔
895
            throw FileNotSupportedException::mimeRestricted($mimeType, $actuallyAllowed);
12✔
896
        }
897

898
        return $mimeType;
174✔
899
    }
900

901
    /**
902
     * Ensure that the file's extension is allowed.
903
     * @param  string $extension
904
     * @param  bool $toLower
905
     * @return string
906
     * @throws FileNotSupportedException If the file extension is not allowed
907
     */
908
    private function verifyExtension(string $extension, bool $toLower = true): string
909
    {
910
        $extensionLower = strtolower($extension);
168✔
911
        $allowed = $this->config->allowedExtensions;
168✔
912
        $forbidden = $this->config->forbiddenExtensions;
168✔
913
        $actuallyAllowed = array_diff($allowed, $forbidden);
168✔
914
        if (!empty($allowed) && !in_array($extensionLower, $actuallyAllowed)) {
168✔
915
            throw FileNotSupportedException::extensionRestricted($extensionLower, $actuallyAllowed);
6✔
916
        }
917
        if (empty($allowed) && in_array($extensionLower, $forbidden)) {
168✔
NEW
918
            throw FileNotSupportedException::extensionRestricted($extensionLower, $actuallyAllowed);
×
919
        }
920

921
        return $toLower ? $extensionLower : $extension;
168✔
922
    }
923

924
    /**
925
     * Verify that the file being uploaded is not larger than the maximum.
926
     * @param  int $size
927
     * @return int
928
     * @throws FileSizeException If the file is too large
929
     */
930
    private function verifyFileSize(int $size): int
931
    {
932
        $max = $this->config->maxUploadSize;
186✔
933
        if ($max > 0 && $size > $max) {
186✔
934
            throw FileSizeException::fileIsTooBig($size, $max);
6✔
935
        }
936

937
        return $size;
186✔
938
    }
939

940
    private function verifyHashes(): void
941
    {
942
        foreach ($this->config->expectedHashes as $algo => $expectedHash) {
108✔
943
            if ($expectedHash === null) {
12✔
944
                return;
×
945
            }
946

947
            $actualHash = $this->source->hash($algo);
12✔
948
            if ($actualHash !== $expectedHash) {
12✔
949
                throw InvalidHashException::hashMismatch(
6✔
950
                    $algo,
6✔
951
                    $expectedHash,
6✔
952
                    $actualHash
6✔
953
                );
6✔
954
            }
955
        }
956
    }
957

958
    /**
959
     * Verify that the intended destination is available and handle any duplications.
960
     * @param  Media $model
961
     * @return void
962
     *
963
     * @throws FileExistsException
964
     */
965
    private function verifyDestination(Media $model): void
966
    {
967
        $storage = $this->filesystem->disk($model->disk);
114✔
968

969
        if ($storage->exists($model->getDiskPath())) {
114✔
970
            $this->handleDuplicate($model);
36✔
971
        }
972
    }
973

974
    /**
975
     * Decide what to do about duplicated files.
976
     *
977
     * @param  Media $model
978
     * @return Media
979
     * @throws FileExistsException If directory is not writable or file already exists at the destination and on_duplicate is set to 'error'
980
     */
981
    private function handleDuplicate(Media $model): Media
982
    {
983
        switch ($this->config->onDuplicate) {
60✔
984
            case OnDuplicateBehaviour::Error:
60✔
985
                throw FileExistsException::fileExists($model->getDiskPath());
6✔
986
            case OnDuplicateBehaviour::Replace:
54✔
987
                $this->deleteExistingMedia($model);
12✔
988
                break;
12✔
989
            case OnDuplicateBehaviour::ReplaceWithVariants:
42✔
990
                $this->deleteExistingMedia($model, true);
6✔
991
                break;
6✔
992
            case OnDuplicateBehaviour::Update:
36✔
993
                $original = $model->newQuery()
12✔
994
                   ->where('disk', $model->disk)
12✔
995
                   ->where('directory', $model->directory)
12✔
996
                   ->where('filename', $model->filename)
12✔
997
                   ->where('extension', $model->extension)
12✔
998
                   ->first();
12✔
999

1000
                if ($original) {
12✔
1001
                    $model->{$model->getKeyName()} = $original->getKey();
6✔
1002
                    $model->exists = true;
6✔
1003
                }
1004
                break;
12✔
1005
            case OnDuplicateBehaviour::Increment:
24✔
1006
            default:
1007
                $model->filename = $this->generateUniqueFilename($model);
24✔
1008
        }
1009
        return $model;
54✔
1010
    }
1011

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

1037
    /**
1038
     * Delete the file on disk.
1039
     * @param  Media $model
1040
     * @return void
1041
     */
1042
    private function deleteExistingFile(Media $model): void
1043
    {
1044
        $this->filesystem->disk($model->disk)->delete($model->getDiskPath());
18✔
1045
    }
1046

1047
    /**
1048
     * Increment model's filename until one is found that doesn't already exist.
1049
     * @param  Media $model
1050
     * @return string
1051
     */
1052
    private function generateUniqueFilename(Media $model): string
1053
    {
1054
        $storage = $this->filesystem->disk($model->disk);
24✔
1055
        $counter = 0;
24✔
1056
        do {
1057
            $filename = "{$model->filename}";
24✔
1058
            if ($counter > 0) {
24✔
1059
                $filename .= '-' . $counter;
24✔
1060
            }
1061
            $path = "{$model->directory}/{$filename}.{$model->extension}";
24✔
1062
            ++$counter;
24✔
1063
        } while ($storage->exists($path));
24✔
1064

1065
        return $filename;
24✔
1066
    }
1067

1068
    /**
1069
     * Generate the model's filename.
1070
     * @return string
1071
     */
1072
    private function generateFilename(): string
1073
    {
1074
        if ($this->config->destinationFilename) {
114✔
1075
            return $this->config->destinationFilename;
84✔
1076
        }
1077

1078
        if ($this->config->hashFilenameAlgorithm) {
30✔
1079
            return $this->source->hash($this->config->hashFilenameAlgorithm);
12✔
1080
        }
1081

1082
        $filename = $this->source->filename();
18✔
1083

1084
        if ($filename === null) {
18✔
1085
            ConfigurationException::cannotInferFilename();
×
1086
        }
1087

1088
        return File::sanitizeFileName(
18✔
1089
            $filename,
18✔
1090
            null,
18✔
1091
            $this->config->forbiddenExtensions
18✔
1092
        );
18✔
1093
    }
1094

1095
    private function writeToDisk(Media $model): void
1096
    {
1097
        $this->filesystem->disk($model->disk)
114✔
1098
            ->put(
114✔
1099
                $model->getDiskPath(),
114✔
1100
                $this->source->getStream(),
114✔
1101
                $this->getOptions()
114✔
1102
            );
114✔
1103
    }
1104

1105
    public function getOptions(): array
1106
    {
1107
        $options = $this->config->filesystemOptions;
120✔
1108
        if (!isset($options['visibility'])) {
120✔
1109
            $options['visibility'] = $this->getVisibility();
120✔
1110
        }
1111
        return $options;
120✔
1112
    }
1113

1114
    public function sanitizeFile(Media $model): void
1115
    {
1116
        if (empty($this->config->fileSanitizers)) {
114✔
NEW
1117
            return;
×
1118
        }
1119
        foreach ($this->config->fileSanitizers as $sanitizerClass) {
114✔
1120
            if (!is_a($sanitizerClass, SanitizerInterface::class, true)) {
114✔
NEW
1121
                throw ConfigurationException::invalidSanitizer($sanitizerClass);
×
1122
            }
1123
            $sanitizer = app($sanitizerClass);
114✔
1124
            if ($sanitizer->isApplicable(
114✔
1125
                $model->mime_type,
114✔
1126
                $model->extension,
114✔
1127
                $model->aggregate_type
114✔
1128
            )) {
114✔
1129
                $result = $sanitizer->sanitize($this->source->getStream());
6✔
1130
                if ($result !== null) {
6✔
1131
                    $this->source = new StreamAdapter($result);
6✔
1132
                }
1133
            }
1134
        }
1135
        // Update the model's size in case the sanitizer modified the file contents
1136
        $model->size = $this->source->size() ?? $model->size;
114✔
1137
    }
1138

1139
    /**
1140
     * @param Media $model
1141
     * @return void
1142
     * @throws Exceptions\ImageManipulationException
1143
     */
1144
    public function manipulateImage(Media $model): void
1145
    {
1146
        if (empty($this->config->imageManipulation)
102✔
1147
            || $model->aggregate_type !== Media::TYPE_IMAGE
102✔
1148
        ) {
1149
            return;
96✔
1150
        }
1151
        $manipulation = $this->config->imageManipulation;
6✔
1152
        $this->source = $this->imageManipulator->manipulateUpload(
6✔
1153
            $model,
6✔
1154
            $this->source,
6✔
1155
            $manipulation
6✔
1156
        );
6✔
1157
    }
1158
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc