diff --git a/UPGRADING.md b/UPGRADING.md index 50ca8c1..fcebc0f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -3,7 +3,16 @@ ## 4.x to 5.x * Two columns added to the `media` table: `variant_name` (varchar) and `original_media_id` (should match `media.id` column type) + + ```php + $table->string('variant_name', 255)->nullable(); + $table->integer('original_media_id')->unsigned()->nullable(); + $table->foreign('original_media_id') + ->references('id')->on('media') + ->nullOnDelete(); + ``` * `Plank\Mediable\MediaUploaderFacade` moved to `Plank\Mediable\Facades\MediaUploader` +* Directory and filename validation now only allows URL and filesystem safe ASCII characters (alphanumeric plus `.`, `-`, `_`, and `/` for directories). Will automatically attempt to transliterate UTF-8 accented characters and ligatures into their ASCII equivalent, all other characters will be converted to hyphens. * The following methods now include an extra `$withVariants` parameter : * `Mediable::scopeWithMedia()` * `Mediable::scopeWithMediaMatchAll()` diff --git a/docs/source/variants.rst b/docs/source/variants.rst index 7bc170d..283b356 100644 --- a/docs/source/variants.rst +++ b/docs/source/variants.rst @@ -78,12 +78,12 @@ The ImageManipulation class also offers a fluent interface for defining how the :: toJpegFormat(); - $manipulation->toPngFormat(); - $manipulation->toGifFormat(); - $manipulation->toTiffFormat(); - $manipulation->toBmpFormat(); - $manipulation->toWebpFormat(); + $manipulation->outputJpegFormat(); + $manipulation->outputPngFormat(); + $manipulation->outputGifFormat(); + $manipulation->outputTiffFormat(); + $manipulation->outputBmpFormat(); + $manipulation->outputWebpFormat(); $manipulation->setOutputFormat($format); If outputting to JPEG format, it is also possible to set the desired level of lossy compression, from 0 (low quality, smaller file size) to 100 (high quality, larger file size). Defaults to 90. This value is ignored by other formats. @@ -91,18 +91,43 @@ If outputting to JPEG format, it is also possible to set the desired level of lo :: toJpegFormat()->setOutputQuality(50); + $manipulation->outputJpegFormat()->setOutputQuality(50); .. note:: Intervention/image requires different dependency libraries to be installed in order to output different format. Review the `intervention image documentation `_ for more details. +Output Destination +^^^^^^^^^^^^^^^^^^ + +By default, variants will be created in the same disk and directory as the original file, with a filename that includes the variant name as as suffix. You can choose to customize the output disk, directory and filename. + +.. + + toDisk('uploads'); + $manipulator->toDirectory('files/variants'); + + // shorthand for the above + $manipulator->toDestination('uploads', 'files/variants'); + + $manipulator->useFilename('my-custom-filename'); + $manipulator->useHashForFilename(); + $manipulator->useOriginalFilename(); //restore default behaviour + +If another file exists at the output destination, the ImageManipulator will attempt to find a unique filename by appending an incrementing number. This can be configured to throw an exception instead if a conflict is discovered. + +:: + + onDuplicateIncrement(); // default behaviour + $manipulator->onDuplicateError(); + + Before Save Callback ^^^^^^^^^^^^^^^^^^^^ -You can specify a callback which will be invoked after the image manipulation is processed, but before the file is written to disk and a ``Media`` record is written to the database. The callback will be passed the populated ``Media`` record, which can be modified. - -By default, variants will be created in the same disk and directory as the original file, with a filename that includes the variant name as the suffix. However, these fields can be modified using this callback, which will change the output destination. The callback can also be used to set additional fields. +You can specify a callback which will be invoked after the image manipulation is processed, but before the file is written to disk and a ``Media`` record is written to the database. The callback will be passed the populated ``Media`` record, which can be modified. This can also be used to set additional fields. :: @@ -112,6 +137,8 @@ By default, variants will be created in the same disk and directory as the origi $media->someOtherField = 'potato'; }); +.. note:: Modifying the disk, directory, filename, or extension fields will cause the output destination to be changed accordingly. Duplicates will be checked again against the new location. + Creating Variants ----------------- @@ -134,6 +161,18 @@ Depending on the size of the files and the nature of the manipulations, creating CreateImageVariants::dispatch($media, ['square', 'bw-square']); +Recreating Variants +^^^^^^^^^^^^^^^^^^^ + +If a variant with the requested variant name already exists for the provided media, the ``ImageManipulator`` will skip over it. If you need to regenerate a variant (e.g. because the manipulations changed), you can tell the ``ImageManipulator`` to recreate the variant by passing an additional ``$forceRecreate`` parameter. + +:: + + guess($mimeType); + if (class_exists(ExtensionGuesser::class)) { + return ExtensionGuesser::getInstance()->guess($mimeType); + } + + return null; } } diff --git a/src/ImageManipulation.php b/src/ImageManipulation.php index 1df2c0d..a9efa72 100644 --- a/src/ImageManipulation.php +++ b/src/ImageManipulation.php @@ -2,6 +2,9 @@ namespace Plank\Mediable; +use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; +use Plank\Mediable\Helpers\File; + class ImageManipulation { public const FORMAT_JPG = 'jpg'; @@ -28,6 +31,9 @@ class ImageManipulation self::FORMAT_WEBP => 'image/webp' ]; + public const ON_DUPLICATE_INCREMENT = 'increment'; + public const ON_DUPLICATE_ERROR = 'error'; + /** @var callable */ private $callback; @@ -37,6 +43,21 @@ class ImageManipulation /** @var int */ private $outputQuality = 90; + /** @var string|null */ + private $disk; + + /** @var string|null */ + private $directory; + + /** @var string|null */ + private $filename; + + /** @var bool */ + private $hashFilename = false; + + /** @var string */ + private $onDuplicateBehaviour = self::ON_DUPLICATE_INCREMENT; + /** @var callable|null */ private $beforeSave; @@ -51,9 +72,9 @@ public static function make(callable $callback) } /** - * @return \Closure + * @return callable */ - public function getCallback(): \Closure + public function getCallback(): callable { return $this->callback; } @@ -99,7 +120,7 @@ public function setOutputFormat(?string $outputFormat): self /** * @return $this */ - public function toJpegFormat(): self + public function outputJpegFormat(): self { $this->setOutputFormat(self::FORMAT_JPG); @@ -109,7 +130,7 @@ public function toJpegFormat(): self /** * @return $this */ - public function toPngFormat(): self + public function outputPngFormat(): self { $this->setOutputFormat(self::FORMAT_PNG); @@ -119,7 +140,7 @@ public function toPngFormat(): self /** * @return $this */ - public function toGifFormat(): self + public function outputGifFormat(): self { $this->setOutputFormat(self::FORMAT_GIF); @@ -129,7 +150,7 @@ public function toGifFormat(): self /** * @return $this */ - public function toTiffFormat(): self + public function outputTiffFormat(): self { $this->setOutputFormat(self::FORMAT_TIFF); @@ -139,7 +160,7 @@ public function toTiffFormat(): self /** * @return $this */ - public function toBmpFormat(): self + public function outputBmpFormat(): self { $this->setOutputFormat(self::FORMAT_BMP); @@ -149,7 +170,7 @@ public function toBmpFormat(): self /** * @return $this */ - public function toWebpFormat(): self + public function outputWebpFormat(): self { $this->setOutputFormat(self::FORMAT_WEBP); @@ -164,6 +185,143 @@ public function getBeforeSave(): ?callable return $this->beforeSave; } + /** + * Set the filesystem disk and relative directory where the file will be saved. + * + * @param string $disk + * @param string $directory + * + * @return $this + */ + public function toDestination(string $disk, string $directory): self + { + return $this->toDisk($disk)->toDirectory($directory); + } + + /** + * Set the filesystem disk on which the file will be saved. + * + * @param string $disk + * + * @return $this + */ + public function toDisk(string $disk): self + { + if (!array_key_exists($disk, config('filesystems.disks', []))) { + throw ConfigurationException::diskNotFound($disk); + } + $this->disk = $disk; + + return $this; + } + + /** + * @return string|null + */ + public function getDisk(): ?string + { + return $this->disk; + } + + /** + * Set the directory relative to the filesystem disk at which the file will be saved. + * @param string $directory + * @return $this + */ + public function toDirectory(string $directory): self + { + $this->directory = File::sanitizePath($directory); + + return $this; + } + + /** + * @return string|null + */ + public function getDirectory(): ?string + { + return $this->directory; + } + + /** + * Specify the filename to copy to the file to. + * @param string $filename + * @return $this + */ + public function useFilename(string $filename): self + { + $this->filename = File::sanitizeFilename($filename); + $this->hashFilename = false; + + return $this; + } + + /** + * Indicates to the uploader to generate a filename using the file's MD5 hash. + * @return $this + */ + public function useHashForFilename(): self + { + $this->hashFilename = true; + $this->filename = null; + + return $this; + } + + /** + * Restore the default behaviour of using the source file's filename. + * @return $this + */ + public function useOriginalFilename(): self + { + $this->filename = null; + $this->hashFilename = false; + + return $this; + } + + /** + * @return string|null + */ + public function getFilename(): ?string + { + return $this->filename; + } + + /** + * @return bool + */ + public function usingHashForFilename(): bool + { + return $this->hashFilename; + } + + /** + * @return $this + */ + public function onDuplicateIncrement(): self + { + $this->onDuplicateBehaviour = self::ON_DUPLICATE_INCREMENT; + return $this; + } + + /** + * @return $this + */ + public function onDuplicateError(): self + { + $this->onDuplicateBehaviour = self::ON_DUPLICATE_ERROR; + return $this; + } + + /** + * @return string + */ + public function getOnDuplicateBehaviour(): string + { + return $this->onDuplicateBehaviour; + } + /** * @param callable $beforeSave * @return $this diff --git a/src/ImageManipulator.php b/src/ImageManipulator.php index 85f62ce..dad45e3 100644 --- a/src/ImageManipulator.php +++ b/src/ImageManipulator.php @@ -5,6 +5,7 @@ use Illuminate\Filesystem\FilesystemManager; use Intervention\Image\ImageManager; use Plank\Mediable\Exceptions\ImageManipulationException; +use Psr\Http\Message\StreamInterface; class ImageManipulator { @@ -58,13 +59,37 @@ public function getVariantDefinition(string $variantName): ImageManipulation /** * @param Media $media * @param string $variantName + * @param bool $forceRecreate * @return Media * @throws ImageManipulationException + * @throws \Illuminate\Contracts\Filesystem\FileExistsException */ - public function createImageVariant(Media $media, string $variantName): Media - { + public function createImageVariant( + Media $media, + string $variantName, + bool $forceRecreate = false + ): Media { $this->validateMedia($media); + $modelClass = config('mediable.model'); + /** @var Media $variant */ + $variant = new $modelClass(); + $recreating = false; + $originalVariant = null; + + // don't recreate if that variant already exists for the model + if ($media->hasVariant($variantName)) { + $variant = $media->findVariant($variantName); + if ($forceRecreate) { + // replace the existing variant + $recreating = true; + $originalVariant = clone $variant; + } else { + // variant already exists, nothing more to do + return $variant; + } + } + $manipulation = $this->getVariantDefinition($variantName); $outputFormat = $this->determineOutputFormat($manipulation, $media); @@ -78,31 +103,43 @@ public function createImageVariant(Media $media, string $variantName): Media $manipulation->getOutputQuality() ); - $modelClass = config('mediable.model'); - /** @var Media $newMedia */ - $newMedia = new $modelClass(); - $newMedia->disk = $media->disk; - $newMedia->directory = $media->directory; - $newMedia->filename = sprintf('%s-%s', $media->filename, $variantName); - $newMedia->extension = $outputFormat; - $newMedia->mime_type = $this->getMimeTypeForOutputFormat($outputFormat); - $newMedia->aggregate_type = Media::TYPE_IMAGE; - $newMedia->size = $outputStream->getSize(); - $newMedia->variant_name = $variantName; - $newMedia->original_media_id = $media->isOriginal() + $variant->variant_name = $variantName; + $variant->original_media_id = $media->isOriginal() ? $media->getKey() : $media->original_media_id; // attach variants of variants to the same original + $variant->disk = $manipulation->getDisk() ?? $media->disk; + $variant->directory = $manipulation->getDirectory() ?? $media->directory; + $variant->filename = $this->determineFilename( + $media->findOriginal(), + $manipulation, + $variant, + $outputStream + ); + $variant->extension = $outputFormat; + $variant->mime_type = $this->getMimeTypeForOutputFormat($outputFormat); + $variant->aggregate_type = Media::TYPE_IMAGE; + $variant->size = $outputStream->getSize(); + + $this->checkForDuplicates($variant, $manipulation, $originalVariant); if ($beforeSave = $manipulation->getBeforeSave()) { - $beforeSave($newMedia); + $beforeSave($variant, $originalVariant); + // destination may have been changed, check for duplicates again + $this->checkForDuplicates($variant, $manipulation, $originalVariant); + } + + if ($recreating) { + // delete the original file for that variant + $this->filesystem->disk($originalVariant->disk) + ->delete($originalVariant->getDiskPath()); } - $this->filesystem->disk($newMedia->disk) - ->writeStream($newMedia->getDiskPath(), $outputStream->detach()); + $this->filesystem->disk($variant->disk) + ->writeStream($variant->getDiskPath(), $outputStream->detach()); - $newMedia->save(); + $variant->save(); - return $newMedia; + return $variant; } private function getMimeTypeForOutputFormat(string $outputFormat): string @@ -146,10 +183,89 @@ private function determineOutputFormat( throw ImageManipulationException::unknownOutputFormat(); } + public function determineFilename( + Media $originalMedia, + ImageManipulation $manipulation, + Media $variant, + StreamInterface $stream + ): string { + if ($filename = $manipulation->getFilename()) { + return $filename; + } + + if ($manipulation->usingHashForFilename()) { + return $this->getHashFromStream($stream); + } + return sprintf('%s-%s', $originalMedia->filename, $variant->variant_name); + } + public function validateMedia(Media $media) { if ($media->aggregate_type != Media::TYPE_IMAGE) { throw ImageManipulationException::invalidMediaType($media->aggregate_type); } } + + private function getHashFromStream(StreamInterface $stream): string + { + $stream->rewind(); + $hash = hash_init('md5'); + while ($chunk = $stream->read(64)) { + hash_update($hash, $chunk); + } + $filename = hash_final($hash); + $stream->rewind(); + + return $filename; + } + + private function checkForDuplicates( + Media $variant, + ImageManipulation $manipulation, + Media $originalVariant = null + ) { + if ($originalVariant + && $variant->disk === $originalVariant->disk + && $variant->getDiskPath() === $originalVariant->getDiskPath() + ) { + // same as the original, no conflict as we are going to replace the file anyways + return; + } + + if (!$this->filesystem->disk($variant->disk)->has($variant->getDiskPath())) { + // no conflict, carry on + return; + } + + switch ($manipulation->getOnDuplicateBehaviour()) { + case ImageManipulation::ON_DUPLICATE_ERROR: + throw ImageManipulationException::fileExists($variant->getDiskPath()); + + case ImageManipulation::ON_DUPLICATE_INCREMENT: + default: + $variant->filename = $this->generateUniqueFilename($variant); + break; + } + } + + /** + * Increment model's filename until one is found that doesn't already exist. + * @param Media $model + * @return string + */ + private function generateUniqueFilename(Media $model): string + { + $storage = $this->filesystem->disk($model->disk); + $counter = 0; + do { + $filename = "{$model->filename}"; + if ($counter > 0) { + $filename .= '-' . $counter; + } + $path = "{$model->directory}/{$filename}.{$model->extension}"; + ++$counter; + } while ($storage->has($path)); + + return $filename; + } } diff --git a/src/Jobs/CreateImageVariants.php b/src/Jobs/CreateImageVariants.php index 51578fc..0f64267 100644 --- a/src/Jobs/CreateImageVariants.php +++ b/src/Jobs/CreateImageVariants.php @@ -24,19 +24,25 @@ class CreateImageVariants implements ShouldQueue */ private $model; + /** + * @var bool + */ + private $forceRecreate; + /** * CreateImageVariants constructor. * @param Media $model * @param string|string[] $variantNames * @throws ImageManipulationException */ - public function __construct(Media $model, $variantNames) + public function __construct(Media $model, $variantNames, bool $forceRecreate = false) { $variantNames = (array) $variantNames; $this->validate($model, $variantNames); $this->variantNames = $variantNames; $this->model = $model; + $this->forceRecreate = $forceRecreate; } public function handle() @@ -44,7 +50,8 @@ public function handle() foreach ($this->getVariantNames() as $variantName) { $this->getImageManipulator()->createImageVariant( $this->getModel(), - $variantName + $variantName, + $this->getForceRecreate() ); } } @@ -83,4 +90,12 @@ private function getImageManipulator(): ImageManipulator { return app(ImageManipulator::class); } + + /** + * @return bool + */ + public function getForceRecreate(): bool + { + return $this->forceRecreate; + } } diff --git a/src/Media.php b/src/Media.php index f986e22..bbc3156 100644 --- a/src/Media.php +++ b/src/Media.php @@ -186,7 +186,11 @@ public function findVariant(string $variantName): ?Media return $this; } - return $this->originalMedia->variants->first($filter); + if ($this->originalMedia) { + return $this->originalMedia->variants->first($filter); + } + + return null; } public function findOriginal(): Media diff --git a/src/MediaMover.php b/src/MediaMover.php index 8ac9980..de2aa75 100644 --- a/src/MediaMover.php +++ b/src/MediaMover.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Filesystem\FilesystemManager; use Plank\Mediable\Exceptions\MediaMoveException; +use Plank\Mediable\Helpers\File; /** * Media Mover Class. @@ -41,8 +42,7 @@ public function move(Media $media, string $directory, string $filename = null): $storage = $this->filesystem->disk($media->disk); $filename = $this->cleanFilename($media, $filename); - - $directory = trim($directory, '/'); + $directory = File::sanitizePath($directory); $targetPath = $directory . '/' . $filename . '.' . $media->extension; if ($storage->has($targetPath)) { @@ -78,7 +78,7 @@ public function moveToDisk(Media $media, string $disk, string $directory, string $targetStorage = $this->filesystem->disk($disk); $filename = $this->cleanFilename($media, $filename); - $directory = trim($directory, '/'); + $directory = File::sanitizePath($directory); $targetPath = $directory . '/' . $filename . '.' . $media->extension; if ($targetStorage->has($targetPath)) { @@ -116,8 +116,8 @@ public function copyTo(Media $media, string $directory, string $filename = null) $storage = $this->filesystem->disk($media->disk); $filename = $this->cleanFilename($media, $filename); + $directory = File::sanitizePath($directory); - $directory = trim($directory, '/'); $targetPath = $directory . '/' . $filename . '.' . $media->extension; if ($storage->has($targetPath)) { @@ -164,7 +164,7 @@ public function copyToDisk(Media $media, string $disk, string $directory, string $targetStorage = $this->filesystem->disk($disk); $filename = $this->cleanFilename($media, $filename); - $directory = trim($directory, '/'); + $directory = File::sanitizePath($directory); $targetPath = $directory . '/' . $filename . '.' . $media->extension; if ($targetStorage->has($targetPath)) { @@ -193,7 +193,9 @@ public function copyToDisk(Media $media, string $disk, string $directory, string protected function cleanFilename(Media $media, ?string $filename): string { if ($filename) { - return $this->removeExtensionFromFilename($filename, $media->extension); + return File::sanitizeFileName( + $this->removeExtensionFromFilename($filename, $media->extension) + ); } return $media->filename; diff --git a/src/MediaUploader.php b/src/MediaUploader.php index 9d25c43..d4ca97c 100644 --- a/src/MediaUploader.php +++ b/src/MediaUploader.php @@ -163,7 +163,7 @@ public function toDisk(string $disk): self */ public function toDirectory(string $directory): self { - $this->directory = trim($this->sanitizePath($directory), DIRECTORY_SEPARATOR); + $this->directory = File::sanitizePath($directory); return $this; } @@ -175,7 +175,7 @@ public function toDirectory(string $directory): self */ public function useFilename(string $filename): self { - $this->filename = $this->sanitizeFilename($filename); + $this->filename = File::sanitizeFilename($filename); $this->hashFilename = false; return $this; @@ -818,7 +818,6 @@ private function handleDuplicate(Media $model): Media switch ($this->config['on_duplicate'] ?? MediaUploader::ON_DUPLICATE_INCREMENT) { case static::ON_DUPLICATE_ERROR: throw FileExistsException::fileExists($model->getDiskPath()); - break; case static::ON_DUPLICATE_REPLACE: $this->deleteExistingMedia($model); break; @@ -902,7 +901,7 @@ private function generateFilename(): string return $this->generateHash(); } - return $this->sanitizeFileName($this->source->filename()); + return File::sanitizeFileName($this->source->filename()); } /** @@ -923,25 +922,7 @@ private function generateHash(): string return hash_final($ctx); } - /** - * Remove any disallowed characters from a directory value. - * @param string $path - * @return string - */ - private function sanitizePath(string $path): string - { - return str_replace(['#', '?', '\\'], '-', $path); - } - /** - * Remove any disallowed characters from a filename. - * @param string $file - * @return string - */ - private function sanitizeFileName(string $file): string - { - return str_replace(['#', '?', '\\', '/'], '-', $file); - } private function writeToDisk(Media $model): void { diff --git a/tests/Integration/Helpers/FileTest.php b/tests/Integration/Helpers/FileTest.php index 0109290..dd519e1 100644 --- a/tests/Integration/Helpers/FileTest.php +++ b/tests/Integration/Helpers/FileTest.php @@ -27,4 +27,20 @@ public function test_it_guesses_the_extension_given_a_mime_type() { $this->assertEquals('png', File::guessExtension('image/png')); } + + public function test_it_sanitizes_filenames() + { + $this->assertEquals( + 'hello-world-what-s_new-with.you', + File::sanitizeFileName("héllo/world! \\ \t whàt\'ς_new with.you?") + ); + } + + public function test_it_sanitizes_paths() + { + $this->assertEquals( + 'hello/world-what-s_new-with.you', + File::sanitizePath("/héllo/world! \\ \t whàt\'ς_new with.you??") + ); + } } diff --git a/tests/Integration/ImageManipulationTest.php b/tests/Integration/ImageManipulationTest.php index 81b944c..eea4f5b 100644 --- a/tests/Integration/ImageManipulationTest.php +++ b/tests/Integration/ImageManipulationTest.php @@ -9,14 +9,14 @@ class ImageManipulationTest extends TestCase { public function test_can_get_set_manipulation_callback() { - $callback = $this->getCallback(); + $callback = $this->getMockCallable(); $manipulation = new ImageManipulation($callback); $this->assertSame($callback, $manipulation->getCallback()); } public function test_can_get_set_output_quality() { - $manipulation = new ImageManipulation($this->getCallback()); + $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertEquals(90, $manipulation->getOutputQuality()); $manipulation->setOutputQuality(-100); $this->assertEquals(0, $manipulation->getOutputQuality()); @@ -28,37 +28,82 @@ public function test_can_get_set_output_quality() public function test_can_get_set_output_format() { - $manipulation = new ImageManipulation($this->getCallback()); + $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertNull($manipulation->getOutputFormat()); $manipulation->setOutputFormat('jpg'); $this->assertEquals('jpg', $manipulation->getOutputFormat()); - $manipulation->toBmpFormat(); + $manipulation->outputBmpFormat(); $this->assertEquals('bmp', $manipulation->getOutputFormat()); - $manipulation->toGifFormat(); + $manipulation->outputGifFormat(); $this->assertEquals('gif', $manipulation->getOutputFormat()); - $manipulation->toPngFormat(); + $manipulation->outputPngFormat(); $this->assertEquals('png', $manipulation->getOutputFormat()); - $manipulation->toTiffFormat(); + $manipulation->outputTiffFormat(); $this->assertEquals('tif', $manipulation->getOutputFormat()); - $manipulation->toWebpFormat(); + $manipulation->outputWebpFormat(); $this->assertEquals('webp', $manipulation->getOutputFormat()); - $manipulation->toJpegFormat(); + $manipulation->outputJpegFormat(); $this->assertEquals('jpg', $manipulation->getOutputFormat()); } public function test_can_get_set_before_save_callback() { - $callback = $this->getCallback(); - $manipulation = new ImageManipulation($this->getCallback()); + $callback = $this->getMockCallable(); + $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertNull($manipulation->getBeforeSave()); $manipulation->beforeSave($callback); $this->assertSame($callback, $manipulation->getBeforeSave()); } - private function getCallback() + public function test_destination_setters() { - return function () { - }; + $manipulation = new ImageManipulation($this->getMockCallable()); + + $this->assertNull($manipulation->getDisk()); + $this->assertNull($manipulation->getDirectory()); + $this->assertNull($manipulation->getFilename()); + $this->assertFalse($manipulation->usingHashForFilename()); + + $manipulation->toDisk('tmp'); + $this->assertEquals('tmp', $manipulation->getDisk()); + + $manipulation->toDirectory('bar'); + $this->assertEquals('bar', $manipulation->getDirectory()); + + $manipulation->toDestination('uploads', 'bat'); + $this->assertEquals('uploads', $manipulation->getDisk()); + $this->assertEquals('bat', $manipulation->getDirectory()); + + $manipulation->useFilename('potato'); + $this->assertEquals('potato', $manipulation->getFilename()); + $this->assertFalse($manipulation->usingHashForFilename()); + + $manipulation->useHashForFilename(); + $this->assertNull($manipulation->getFilename()); + $this->assertTrue($manipulation->usingHashForFilename()); + + $manipulation->useOriginalFilename(); + $this->assertNull($manipulation->getFilename()); + $this->assertFalse($manipulation->usingHashForFilename()); + } + + public function test_get_duplicate_behaviours() + { + $manipulation = new ImageManipulation($this->getMockCallable()); + $this->assertEquals( + ImageManipulation::ON_DUPLICATE_INCREMENT, + $manipulation->getOnDuplicateBehaviour() + ); + $manipulation->onDuplicateError(); + $this->assertEquals( + ImageManipulation::ON_DUPLICATE_ERROR, + $manipulation->getOnDuplicateBehaviour() + ); + $manipulation->onDuplicateIncrement(); + $this->assertEquals( + ImageManipulation::ON_DUPLICATE_INCREMENT, + $manipulation->getOnDuplicateBehaviour() + ); } } diff --git a/tests/Integration/ImageManipulatorTest.php b/tests/Integration/ImageManipulatorTest.php index d0e7d76..96cbd2b 100644 --- a/tests/Integration/ImageManipulatorTest.php +++ b/tests/Integration/ImageManipulatorTest.php @@ -68,6 +68,7 @@ function (Image $image) { 'disk' => 'tmp', 'filename' => 'foo', 'extension' => 'psd', + 'mime_type' => 'image/vnd.adobe.photoshop', 'aggregate_type' => 'image' ] ); @@ -89,6 +90,7 @@ public function test_it_can_create_a_variant() 'directory' => 'foo', 'filename' => 'bar', 'extension' => 'png', + 'mime_type' => 'image/png', 'aggregate_type' => 'image' ] ); @@ -135,16 +137,19 @@ public function test_it_can_create_a_variant_of_a_variant() $this->useFilesystem('tmp'); $this->useDatabase(); - $media = $this->makeMedia( + $originalMedia = $this->createMedia(['filename' => 'bar']); + + $media = $this->createMedia( [ 'id' => 20, 'disk' => 'tmp', 'directory' => 'foo', - 'filename' => 'bar', + 'filename' => 'bar-other', 'extension' => 'png', + 'mime_type' => 'image/png', 'aggregate_type' => 'image', 'variant_name' => 'other', - 'original_media_id' => 19 + 'original_media_id' => $originalMedia->getKey() ] ); $this->seedFileForMedia($media, $this->sampleFile()); @@ -159,8 +164,9 @@ function (Image $image) { $imageManipulator->defineVariant('test', $manipulation); $result = $imageManipulator->createImageVariant($media, 'test'); + $this->assertEquals('bar-test', $result->filename); $this->assertEquals('test', $result->variant_name); - $this->assertEquals(19, $result->original_media_id); + $this->assertEquals($originalMedia->getKey(), $result->original_media_id); $this->assertTrue($media->fileExists()); } @@ -190,6 +196,7 @@ public function test_it_can_create_a_variant_of_a_different_format( 'directory' => 'foo', 'filename' => 'bar', 'extension' => 'png', + 'mime_type' => 'image/png', 'aggregate_type' => 'image' ] ); @@ -211,6 +218,368 @@ function (Image $image) { $this->assertTrue($media->fileExists()); } + public function test_it_can_output_to_custom_destination() + { + $this->useFilesystem('tmp'); + $this->useFilesystem('uploads'); + $this->useDatabase(); + + $media = $this->makeMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($media, $this->sampleFile()); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->toDestination('uploads', 'potato')->useFilename('onion'); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + $result = $imageManipulator->createImageVariant($media, 'test'); + + $this->assertEquals('uploads', $result->disk); + $this->assertEquals('potato', $result->directory); + $this->assertEquals('onion', $result->filename); + $this->assertTrue($media->fileExists()); + } + + public function test_it_can_output_to_hash_filename() + { + $this->useFilesystem('tmp'); + $this->useFilesystem('uploads'); + $this->useDatabase(); + + $media = $this->makeMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($media, $this->sampleFile()); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->toDisk('uploads') + ->toDirectory('potato') + ->useHashForFilename(); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + $result = $imageManipulator->createImageVariant($media, 'test'); + + $this->assertEquals('uploads', $result->disk); + $this->assertEquals('potato', $result->directory); + $this->assertSame(1, preg_match('/^[a-f0-9]{32}$/', $result->filename)); + $this->assertTrue($media->fileExists()); + } + + public function test_it_errors_on_duplicate() + { + $this->expectException(ImageManipulationException::class); + $this->useFilesystem('tmp'); + $this->useDatabase(); + + $media = $this->makeMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar-test', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($media, $this->sampleFile()); + $media->filename = 'bar'; + $this->seedFileForMedia($media, $this->sampleFile()); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->onDuplicateError(); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + $imageManipulator->createImageVariant($media, 'test'); + } + + public function test_it_errors_on_duplicate_after_before_save() + { + $this->expectException(ImageManipulationException::class); + $this->useFilesystem('tmp'); + $this->useDatabase(); + + $media = $this->makeMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($media, $this->sampleFile()); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->onDuplicateError() + ->beforeSave( + function (Media $variant) { + $variant->filename = 'bar'; + } + ); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + $imageManipulator->createImageVariant($media, 'test'); + } + + public function test_it_increments_on_duplicate() + { + $this->useFilesystem('tmp'); + $this->useDatabase(); + + $media = $this->makeMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar-test', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($media, $this->sampleFile()); + $media->filename = 'bar-test-1'; + $this->seedFileForMedia($media, $this->sampleFile()); + $media->filename = 'bar'; + $this->seedFileForMedia($media, $this->sampleFile()); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->onDuplicateIncrement(); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + $result = $imageManipulator->createImageVariant($media, 'test'); + + $this->assertEquals('bar-test-2', $result->filename); + } + + public function test_it_increments_on_duplicate_after_before_save() + { + $this->useFilesystem('tmp'); + $this->useDatabase(); + + $media = $this->makeMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($media, $this->sampleFile()); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->onDuplicateIncrement() + ->beforeSave( + function (Media $variant) { + $variant->filename = 'bar'; + } + ); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + $result = $imageManipulator->createImageVariant($media, 'test'); + + $this->assertEquals('bar-1', $result->filename); + } + + public function test_it_skips_existing_variants() + { + $this->useFilesystem('tmp'); + $this->useDatabase(); + + $originalMedia = $this->createMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'aggregate_type' => 'image' + ] + ); + $previousVariant = $this->createMedia( + [ + 'disk' => 'uploads', + 'directory' => 'foo', + 'filename' => 'bar-test', + 'aggregate_type' => 'image', + 'variant_name' => 'test', + 'original_media_id' => $originalMedia->getKey() + ] + ); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + ); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + + // from original + $result = $imageManipulator->createImageVariant($originalMedia, 'test'); + $this->assertEquals($previousVariant->getKey(), $result->getKey()); + $this->assertEquals($previousVariant->filename, $result->filename); + $this->assertEquals($previousVariant->variant_name, $result->variant_name); + $this->assertEquals( + $previousVariant->original_media_id, + $result->original_media_id + ); + + //from variant + $result = $imageManipulator->createImageVariant($previousVariant, 'test'); + $this->assertEquals($previousVariant->getKey(), $result->getKey()); + $this->assertEquals($previousVariant->filename, $result->filename); + $this->assertEquals($previousVariant->variant_name, $result->variant_name); + $this->assertEquals( + $previousVariant->original_media_id, + $result->original_media_id + ); + } + + public function test_it_can_recreate_an_existing_variant() + { + $this->useFilesystem('tmp'); + $this->useDatabase(); + + $originalMedia = $this->createMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($originalMedia, $this->sampleFile()); + $previousVariant = $this->createMedia( + [ + 'disk' => 'uploads', + 'directory' => 'foo', + 'filename' => 'bar-test', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image', + 'variant_name' => 'test', + 'size' => 0, + 'original_media_id' => $originalMedia->getKey() + ] + ); + $this->seedFileForMedia($previousVariant); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + ); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + + // from original + $result = $imageManipulator->createImageVariant($originalMedia, 'test', true); + $this->assertEquals($previousVariant->getKey(), $result->getKey()); + $this->assertGreaterThan(0, $result->size); + + $this->seedFileForMedia($previousVariant, $this->sampleFile()); + + $result2 = $imageManipulator->createImageVariant($previousVariant, 'test', true); + $this->assertEquals($previousVariant->getKey(), $result2->getKey()); + $this->assertEquals($result->size, $result2->size); + } + + public function test_it_can_recreate_existing_variant_to_a_new_destination() + { + $this->useFilesystem('tmp'); + $this->useFilesystem('uploads'); + $this->useDatabase(); + + $originalMedia = $this->createMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image' + ] + ); + $this->seedFileForMedia($originalMedia, $this->sampleFile()); + $previousVariant = $this->createMedia( + [ + 'disk' => 'uploads', + 'directory' => 'foo', + 'filename' => 'bar-test', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'aggregate_type' => 'image', + 'variant_name' => 'test', + 'size' => 0, + 'original_media_id' => $originalMedia->getKey() + ] + ); + $this->seedFileForMedia($previousVariant); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->toDestination('uploads', 'potato'); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + + // from original + $result = $imageManipulator->createImageVariant($originalMedia, 'test', true); + $this->assertEquals($previousVariant->getKey(), $result->getKey()); + $this->assertEquals('uploads', $result->disk); + $this->assertEquals('potato', $result->directory); + $this->assertGreaterThan(0, $result->size); + $this->assertTrue($result->fileExists()); + + $this->assertFalse($previousVariant->fileExists()); + } + public function getManipulator(): ImageManipulator { return new ImageManipulator( diff --git a/tests/Integration/Jobs/CreateImageVariantsTest.php b/tests/Integration/Jobs/CreateImageVariantsTest.php index 12c2bc0..04a4354 100644 --- a/tests/Integration/Jobs/CreateImageVariantsTest.php +++ b/tests/Integration/Jobs/CreateImageVariantsTest.php @@ -25,10 +25,33 @@ public function test_it_will_trigger_image_manipulation() ->willReturn($this->createMock(ImageManipulation::class)); $manipulator->expects($this->exactly(2)) ->method('createImageVariant') - ->withConsecutive([$model, $variant1], [$model, $variant2]); + ->withConsecutive([$model, $variant1, false], [$model, $variant2, false]); app()->instance(ImageManipulator::class, $manipulator); $job = new CreateImageVariants($model, [$variant1, $variant2]); $job->handle(); } + + public function test_it_will_trigger_image_manipulation_recreate() + { + $model = $this->makeMedia(['aggregate_type' => 'image']); + $variant1 = 'foo'; + $variant2 = 'bar'; + + $manipulator = $this->createMock(ImageManipulator::class); + $manipulator->expects($this->once()) + ->method('validateMedia') + ->with($model); + $manipulator->expects($this->exactly(2)) + ->method('getVariantDefinition') + ->withConsecutive([$variant1], [$variant2]) + ->willReturn($this->createMock(ImageManipulation::class)); + $manipulator->expects($this->exactly(2)) + ->method('createImageVariant') + ->withConsecutive([$model, $variant1, true], [$model, $variant2, true]); + app()->instance(ImageManipulator::class, $manipulator); + + $job = new CreateImageVariants($model, [$variant1, $variant2], true); + $job->handle(); + } }