diff --git a/.github/workflows/automated-test.yml b/.github/workflows/automated-test.yml index 35bb616d..d2b43582 100644 --- a/.github/workflows/automated-test.yml +++ b/.github/workflows/automated-test.yml @@ -5,12 +5,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-versions: ['7.4', '8.0', '8.1'] + php-versions: ['8.1', '8.2', '8.3'] prefer-lowest: ['','--prefer-lowest'] name: PHP ${{ matrix.php-versions }} ${{ matrix.prefer-lowest }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: pngquant optipng + version: 1.0 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -23,10 +28,10 @@ jobs: - name: Get composer cache directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-${{ matrix.php-version }}${{ matrix.prefer-lowest }}-composer-${{ hashFiles('**/composer.json') }} @@ -38,13 +43,16 @@ jobs: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run phpunit - run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml -v + run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml env: S3_KEY: ${{ secrets.S3_KEY }} S3_SECRET: ${{ secrets.S3_SECRET }} S3_REGION: ${{ secrets.S3_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} + - name: Run PHPStan + run: vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G + - name: Upload coverage results to Coveralls env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 00e25505..f638970c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ coverage/ .env .idea/ .phpunit.result.cache +.phpunit.cache/ infection/ docs/build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bbce0198..9f74eace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## 6.0.0 + +### Compatibility +- Dropped support for PHP 7.4 and 8.0 +- Dropped Support for Laravel 8 and 9 +- Added Support for PHP 8.3 +- Added Support for Laravel 11 +- Added support for intervention/image 3.0 +- Modernized the database migration files to use more recent Laravel conventions. + +### Mediable + +- Added `MediableInterface` + +### MediaUploader + +- Added support for recording alt attributes on Media (database migration required). MediaUploader now exposes a `withAltAttribute()` method to set the alt attribute on the generated media record. +- Added `MediaUploader::applyImageManipulation()` to make changes to the original uploaded image during the upload process. +- Added `MediaUploader::validateHash()` to ensure that the hash of the uploaded file matches a particular value during upload. Supports any hashing algorithm supported by PHP's `hash()` function. +- By default, the MediaUploader will always use the MIME type inferred from the file contents, regardless of the source. Added the `MediaUploader::preferClientMimeType()` to indicate that the MIME type provided by the source should be used instead, if provided. The default behaviour can be configured with the `'prefer_client_mime_type'` key in the `config/mediable.php` file. +- The `MediaUploader::useHashForFilename()` method now accepts an optional parameter to specify which hashing algorithm to use to generate the filename. Supports any hashing algorithm supported by PHP's `hash()` function. +- MediaUploader will now use the visibility defined on the filesystem disk config if the `makePublic()`/`makePrivate()` methods are not called, instead of assuming public visibility. +- MediaUploader now supports data URL strings as an input source, e.g. `data:image/jpeg;base64,...`. +- If a filename is not provided to the MediaUploader, and none can be inferred from the source, the uploader will throw an exception. +- If the file extension is not available from the source, the uploader will now consistently infer it from the MIME type. Previously this behaviour was inconsistent across different source adapters. + +### SourceAdapters + +All SourceAdapter classes have been significantly refactored. +- All sourceAdapters will now never load the entire file contents into memory (unless it is already in memory) to determine metadata about the file, in order to avoid memory exhaustion when dealing with large files. If reading the file is necessary, most adapters will attempt use a single streamed scan of the file to load all metadata at once, to speed up to the precess. Remote files will be cached to `temp://` to avoid repeated HTTP requests. +- Removed `getStreamResource()` method. The method has been replaced with the `getStream(): StreamInterface`, which returns a PSR-7 stream implementation instead. +- Added `hash(string $algo): string` method which is expected to return the hash of the file contents using the specified algorithm. +- The return type of the `filename()` and `extension()` method is now nullable. If the adapter cannot determine the value from the information available, it should return null. +- Removed the `getContents()` method. The `getStream()->getContents()` method may be used instead. +- Removed the `getSource()` method. No replacement. +- Removed the `path()` method. No replacement. +- Removed the `valid()` method. SourceAdapters should now throw an exception with a more helpful message from the constructor if the source is not valid. + +### ImageManipulation + +- Added support for optimizing manipulated images, using the [spatie/image-optimizer](https://github.com/spatie/image-optimizer/) package, which supports a variety of image optimization tools for different image formats (`jpegoptim`, `pngquant`, `optipng`, `gifsicle`, etc.) +- Default image optimization behaviour can configured in the `config/mediable.php` file to specify the optimization tools to use and their arguments. +- Added `ImageManipulation::noOptimization()` and `ImageManipulation::optimize(?array $optimizers = null)` methods to allow overriding the defaults set in the config file. +- The `ImageManipulation::useHashForFilename()` method now accepts an optional parameter to specify which hashing algorithm to use to generate the filename. Supports any hashing algorithm supported by PHP's `hash()` function. +- The `ImageManipulation::usingHashForFilename()` method has been renamed to `ImageManipulation::isUsingHashForFilename()` to avoid confusion with the `useHashForFilename()` method. + +### Media +- Added `alt` attribute to the Media model. +- The Media class now exposes a dynamic `url` attribute which will generate a URL for the file (equivalent to the `getUrl()` method). + +### Other +- Improved `MediableCollection` annotions to support generic types. +- Added missing type declarations to most property and method signatures. +- Removed the `\Plank\Mediable\Stream` class in favor of the `guzzlehttp/psr7` implementation. This removes the direct dependency on the `psr/http-message` library. +- `\Plank\Mediable\HandlesMediaUploadExceptions::transformMediaUploadException()` parameter and return type changed from `\Exception` to `\Throwable`. +- Added PHPStan static analysis to the test suite. + ## 5.5.0 - 2022-05-09 - Filename and pathname sanitization will use the app locale when transliterating UTF-8 characters to ascii. - Restored original behaviour of treating unrecognized mime types as `application/octet-stream` (changed in recent version of Flysystem) diff --git a/UPGRADING.md b/UPGRADING.md index 9dc98498..9ee29820 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,35 @@ # Upgrading +## 5.x to 6.x + +* Minimum PHP version moved to 8.1 +* Minimum Laravel version moved to 10 +* New database migration file is included with the package. Run `php artisan migrate` to apply the changes. +* Add the `MediableInterface` to all models using the `Mediable` trait. +* To add support for data URLs to the MediaUploader, the following entry should be added to the `source_adapters.pattern` field in `config/mediable.php` + ```php + '^data:/?/?[^,]*,' => Plank\Mediable\SourceAdapters\DataUrlAdapter::class, + ``` +* To specify default handling of inferred vs. client-provided MIME types, the following entry should be added to `config/mediable.php`. If `prefer_client_mime_type` is set to `true`, the MIME type provided by the client will be used when available. If set to `false`, the MIME type will always be inferred from the file contents. Defaults to `false`. + ```php + 'prefer_client_mime_type' => false, + ``` +* All properties now declare their types if able, and a handful of missing method return types have been added. If extending any class or implementing any interface from this package, property types may need to be updated. +* If you have implemented a custom SourceAdapter, you will need to apply the following changes from the `SourceAdapterInterface` interface: + * Implement the `getStream(): StreamInterface` method. + * Implement the `getHash(string $algo): string` method. + * he return type of the `filename()` and `extension()` method is now nullable. If the adapter cannot determine the value from the information available, it should return null. + * Remove the `getContents()` method. The `getStream()->getContents()` method may be used instead. + * Remove the `getSource()` method. No replacement. + * Remove the `path()` method. No replacement. + * Remove the `valid()` method. SourceAdapters should now throw an exception with a more helpful message from the constructor if the source is not valid. +* The `Plank\Mediable\Stream` class has been removed in favor of the `guzzlehttp/psr7` implementation. If you were using this class directly, you will need use another PSR-7 compatible stream wrapper instead (such as Guzzle's). +* To make use of the image optimization feature: + * Install the necessary binaries for the types of images that you are working with. See [spatie/image-optimizer documentation](https://github.com/spatie/image-optimizer/blob/main/README.md#optimization-tools) for installation instructions on various operating systems. + * add the `image_optimization.enabled` and `image_optimization.optimizers` configs to the `config/mediable.php` file. See the [sample configuration file](https://github.com/plank/laravel-mediable/blob/master/config/mediable.php) for a recommended baseline setup. +* The `ImageManipulation::usingHashForFilename()` method has been renamed to `ImageManipulation::isUsingHashForFilename()` to avoid confusion with the `useHashForFilename()` method. +* `\Plank\Mediable\HandlesMediaUploadExceptions::transformMediaUploadException()` parameter and return type changed from `\Exception` to `\Throwable`. + ## 4.x to 5.x * Database migration files are now served from within the package. In your migrations table, rename the `XXXX_XX_XX_XXXXXX_create_mediable_tables.php` entry to `2016_06_27_000000_create_mediable_tables.php` and delete your local copy of the migration file from the /database/migrations directory. If any customizations were made to the tables, those should be defined as one or more separate ALTER table migrations. diff --git a/composer.json b/composer.json index d9178b05..737450a2 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,13 @@ { "name": "plank/laravel-mediable", "description": "A package for easily uploading and attaching media files to models with Laravel", - "keywords": ["media", "image", "uploader", "eloquent", "laravel"], + "keywords": [ + "media", + "image", + "uploader", + "eloquent", + "laravel" + ], "license": "MIT", "authors": [ { @@ -10,40 +16,44 @@ } ], "require": { - "php": ">=7.4.0", + "php": ">=8.1.0", "ext-fileinfo": "*", - "illuminate/support": "^8.83.3|^9.0|^10.0", - "illuminate/filesystem": "^8.83.3|^9.0|^10.0", - "illuminate/database": "^8.83.3|^9.0|^10.0", - "league/flysystem": "^1.1.9|^2.4.2|^3.0.4", - "psr/http-message": "^2.0", - "intervention/image": "^2.7.1", - "guzzlehttp/guzzle": "^6.5.5|^7.4.1", - "symfony/http-foundation": "^5.0.11|^6.0.3" - + "guzzlehttp/guzzle": "^7.4.1", + "guzzlehttp/psr7": "^2.6", + "illuminate/database": "^10.0|^11.0", + "illuminate/filesystem": "^10.0|^11.0", + "illuminate/support": "^10.0|^11.0", + "intervention/image": "^2.7.1|^3.0", + "league/flysystem": "^3.0.4", + "symfony/http-foundation": "^6.0.3|^7.0", + "symfony/mime": "^6.0|^7.0", + "spatie/image-optimizer": "^1.7" }, "require-dev": { - "orchestra/testbench": "^6.6|^7.0|^8.0", - "phpunit/phpunit": "^9.5.13", - "mockery/mockery": "^1.4.2", - "vlucas/phpdotenv": "^4.2.2|^5.4.1", - "guzzlehttp/promises": "^1.5.1", - "league/flysystem-aws-s3-v3" : "^1.0.29|^2.1.0|^3.0", "aws/aws-sdk-php": "^3.166.2", - "php-coveralls/php-coveralls": "^2.5.2", + "doctrine/dbal": "^2.11|^3.0", + "guzzlehttp/promises": "^1.5.1", "laravel/legacy-factories": "^1.3.0", - "doctrine/dbal": "^2.11|^3.0" + "league/flysystem-aws-s3-v3": "^3.0", + "mockery/mockery": "^1.4.2", + "orchestra/testbench": "^8.0|^9.0", + "php-coveralls/php-coveralls": "^2.5.2", + "phpunit/phpunit": "^10.0", + "vlucas/phpdotenv": "^5.4.1", + "phpstan/phpstan": "^1.10" }, "autoload": { "psr-4": { "Plank\\Mediable\\": "src/" } }, - "autoload-dev":{ + "autoload-dev": { "psr-4": { "Plank\\Mediable\\Tests\\": "tests/" }, - "classmap": ["migrations/"] + "classmap": [ + "migrations/" + ] }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/config/mediable.php b/config/mediable.php index 8f40133e..4f14ee98 100644 --- a/config/mediable.php +++ b/config/mediable.php @@ -61,6 +61,12 @@ */ 'allow_unrecognized_types' => false, + /** + * Prefer the client-provided MIME type over the one inferred from the file contents, if provided + * May be slightly faster to compute, but is not guaranteed to be accurate if the source is untrusted + */ + 'prefer_client_mime_type' => false, + /* * Only allow files with specific MIME type(s) to be uploaded */ @@ -213,7 +219,8 @@ 'pattern' => [ '^https?://' => Plank\Mediable\SourceAdapters\RemoteUrlAdapter::class, '^/' => Plank\Mediable\SourceAdapters\LocalPathAdapter::class, - '^[a-zA-Z]:\\\\' => Plank\Mediable\SourceAdapters\LocalPathAdapter::class + '^[a-zA-Z]:\\\\' => Plank\Mediable\SourceAdapters\LocalPathAdapter::class, + '^data:/?/?[^,]*,' => Plank\Mediable\SourceAdapters\DataUrlAdapter::class, ], ], @@ -244,4 +251,55 @@ * Use this if you are renaming the published migrations and want to prevent them from being loaded twice. */ 'ignore_migrations' => false, + + /** + * Configuration for image optimization + */ + 'image_optimization' => [ + /** + * Whether to apply image optimization after performing image manipulations by default + */ + 'enabled' => true, + /** + * array of optimizers to use, which should implement \Spatie\ImageOptimizer\Optimizer + * Each can be passed an array of command line arguments to be passed to the optimizer + */ + 'optimizers' => [ + \Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => [ + '--max=85', + '--strip-all', + '--all-progressive', + ], + \Spatie\ImageOptimizer\Optimizers\Pngquant::class => [ + '--quality=85', + '--force', + '--skip-if-larger', + ], + \Spatie\ImageOptimizer\Optimizers\Optipng::class => [ + '-i0', + '-o2', + '-quiet', + ], + \Spatie\ImageOptimizer\Optimizers\Gifsicle::class => [ + '-b', + '-O3', + ], + \Spatie\ImageOptimizer\Optimizers\Cwebp::class => [ + '-q 80', + '-m 6', + '-pass 10', + '-mt', + ], + \Spatie\ImageOptimizer\Optimizers\Avifenc::class => [ + '-a cq-level=23', + '-j all', + '--min 0', + '--max 63', + '--minalpha 0', + '--maxalpha 63', + '-a end-usage=q', + '-a tune=ssim', + ], + ], + ] ]; diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 4951f44c..4aa469a6 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -40,3 +40,51 @@ Run the migrations to add the required tables to your database. :: $ php artisan migrate + + +Quickstart +----------- + +Add the `Mediable` trait and `MediableInterface` interface to your eloquent models + +:: + + file('thumbnail')) + ->toDestination('s3', 'posts/thumbnails') + ->upload(); + +Attach the records to your models. + +:: + + attachMedia($media, 'thumbnail'); + +Load and display your files + +:: + + find($postId); + echo $post->getMedia('thumbnail')->first()->getUrl(); diff --git a/docs/source/mediable.rst b/docs/source/mediable.rst index bdfe27df..08c904cd 100644 --- a/docs/source/mediable.rst +++ b/docs/source/mediable.rst @@ -3,7 +3,7 @@ Handling Media .. highlight:: php -Add the ``Mediable`` trait to any Eloquent models that you would like to be able to attach media to. +Add the ``Mediable`` trait and the `MediableInterface` interface to any Eloquent models that you would like to be able to attach media to. :: @@ -13,8 +13,9 @@ Add the ``Mediable`` trait to any Eloquent models that you would like to be able use Illuminate\Database\Eloquent\Model; use Plank\Mediable\Mediable; + use Plank\Mediable\MediableInterface; - class Post extends Model + class Post extends Model implements MediableInterface { use Mediable; diff --git a/docs/source/uploader.rst b/docs/source/uploader.rst index 030e267b..2d960ae5 100644 --- a/docs/source/uploader.rst +++ b/docs/source/uploader.rst @@ -24,6 +24,7 @@ The ``fromSource()`` method will accept any of the following: - a stream resource handle. - a URL as a string, beginning with ``http://`` or ``https://``. - an absolute path as a string, beginning with ``/``. +- a base64 or URL-encoded data URL. Specifying Destination ---------------------- @@ -58,17 +59,29 @@ By default, the uploader will copy the source file while maintaining its origina ->useFilename('profile') ->upload(); -You can also tell the uploader to generate a filename based on the MD5 hash of the file's contents. +You can also tell the uploader to generate a filename using a specified hashing algorithm on the file's contents. Supports any algorithm supported by PHP's ``hash()`` function. :: useHashForFilename() + ->useHashForFilename() // default is 'md5' + ->useHashForFilename('sha1') ->upload(); You can restore the default behaviour with ``useOriginalFilename()``. +Adding Alt Text +-------------------- + +You can record alt text attribute for the media record by calling the ``withAltAttribute()`` method. + +:: + withAltAttribute('This is the alt text') + ->upload(); + Handling Duplicates ---------------------- @@ -129,6 +142,12 @@ You can override the most validation configuration values set in ``config/mediab // only allow files of specific aggregate types ->setAllowedAggregateTypes(['image']) + // ensure that the file contents match a provided hash + // second argument is the hash algorithm to use + // supports any algorithm supported by PHP's hash() function + ->validateHash('3ef5e70366086147c2695325d79a25cc', 'md5') + ->validateHash('5e96e1fa58067853219c4cb6d3c1ce01cc5cc8ce', 'sha1') + ->upload(); You can also validate the file without uploading it by calling the ``verifyFile`` method. @@ -150,12 +169,35 @@ If the file does not pass validation, an instance of ``Plank\Mediable\MediaUploa ->verifyFile() +Manipulate images during upload +------------------------------- + +It is possible to edit images during the upload process using the `intervention/image` library. + +:: + + fit(100, 100); + })->outputPngFormat(); + $media = MediaUploader::fromSource($request->file('image')) + ->applyImageManipulation($manipulation); + ->upload() + + // alternatively you can reference a register variant name + $media = MediaUploader::fromSource($request->file('image')) + ->applyImageManipulation('thumbnail') + ->upload() + +If the aggregate type of the file is not `'image'`, the manipulation will be ignored. + +This will load the file contents and apply manipulations synchronously as part of the upload process, which may add latency. The original file is not persisted. To apply manipulations asynchronously on copies of the original file, and for more information on manipulations, see the :ref:`Image Variants ` sections. -Alter Model before upload +Alter Model before saving ------------------------- You can manipulate the model before it's saved by passing a callable to the ``beforeSave`` method. -The callback takes two params, ``$model`` an instance of ``Plank\Mediable\Media`` the current model and ``$source`` an instance of ``Plank\Mediable\SourceAdapters\SourceAdapterInterface`` the current source. +The callback takes two params, ``$model``, an instance of ``Plank\Mediable\Media`` the current model and ``$source``, an instance of ``Plank\Mediable\SourceAdapters\SourceAdapterInterface`` the current source. :: diff --git a/docs/source/variants.rst b/docs/source/variants.rst index 01931593..c3d87258 100644 --- a/docs/source/variants.rst +++ b/docs/source/variants.rst @@ -54,7 +54,7 @@ Before variants can be created, the manipulations to be applied to the images ne 'thumb', ImageManipulation::make(function (Image $image, Media $originalMedia) { $image->fit(32, 32); - })->toPngFormat() + })->outputPngFormat() ); ImageManipulator::defineVariant( @@ -97,6 +97,30 @@ If outputting to JPEG format, it is also possible to set the desired level of lo .. note:: Intervention/image requires different dependency libraries to be installed in order to output different format. Review the `intervention image documentation `_ for more details. +Image Optimizations +^^^^^^^^^^^^^^^^^^^ + +The ImageManipulator is capable of automatically optimizing images after the manipulations have been applied in order to the reduce the file size. + +Before you can use this feature, you must install the optimizer binaries for the image formats that you intend to work with. See the `spatie/image-optimizer documentation `_ for a list of supported packages and installation instructions on different operating systems. + +The optimizers to be used and their arguments can be configured in the ``config/mediable.php`` file. By default, the ImageManipulator will attempt to optimize the image after each manipulation. You can override the default config settings by calling the following methods. + +:: + + noOptimization(); + + // enable optimization for this manipulation + $manipulation->optimize(); + + // enable optimization but override the optimizers to be applied + $manipulation->optimize([Pngquant::class => ['--quality=65']]); + +.. warning:: + Never pass untrusted user input to the optimizer arguments as they will be executed as shell commands! + Output Destination ^^^^^^^^^^^^^^^^^^ @@ -112,7 +136,8 @@ By default, variants will be created in the same disk and directory as the origi $manipulation->toDestination('uploads', 'files/variants'); $manipulation->useFilename('my-custom-filename'); - $manipulation->useHashForFilename(); + $manipulation->useHashForFilename(); // defaults to md5 + $manipulation->useHashForFilename('sha1'); $manipulation->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. diff --git a/migrations/2016_06_27_000000_create_mediable_tables.php b/migrations/2016_06_27_000000_create_mediable_tables.php index 41765658..3168f847 100644 --- a/migrations/2016_06_27_000000_create_mediable_tables.php +++ b/migrations/2016_06_27_000000_create_mediable_tables.php @@ -3,6 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use Plank\Mediable\Media; class CreateMediableTables extends Migration { @@ -17,18 +18,16 @@ public function up() Schema::create( 'media', function (Blueprint $table) { - $table->increments('id'); + $table->id(); $table->string('disk', 32); $table->string('directory'); $table->string('filename'); $table->string('extension', 32); $table->string('mime_type', 128); - $table->string('aggregate_type', 32); - $table->integer('size')->unsigned(); + $table->string('aggregate_type', 32)->index(); + $table->unsignedInteger('size'); $table->timestamps(); - $table->unique(['disk', 'directory', 'filename', 'extension']); - $table->index('aggregate_type'); } ); } @@ -37,19 +36,12 @@ function (Blueprint $table) { Schema::create( 'mediables', function (Blueprint $table) { - $table->integer('media_id')->unsigned(); - $table->string('mediable_type'); - $table->integer('mediable_id')->unsigned(); - $table->string('tag'); - $table->integer('order')->unsigned(); - + $table->foreignIdFor(Media::class)->constrained('media')->cascadeOnDelete(); + $table->morphs('mediable'); + $table->string('tag')->index(); + $table->unsignedInteger('order')->index(); $table->primary(['media_id', 'mediable_type', 'mediable_id', 'tag']); $table->index(['mediable_id', 'mediable_type']); - $table->index('tag'); - $table->index('order'); - $table->foreign('media_id') - ->references('id')->on('media') - ->cascadeOnDelete(); } ); } diff --git a/migrations/2020_10_12_000000_add_variants_to_media.php b/migrations/2020_10_12_000000_add_variants_to_media.php index 7e5dfe3b..83a0b5c0 100644 --- a/migrations/2020_10_12_000000_add_variants_to_media.php +++ b/migrations/2020_10_12_000000_add_variants_to_media.php @@ -4,6 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Plank\Mediable\Media; class AddVariantsToMedia extends Migration { @@ -20,13 +21,10 @@ function (Blueprint $table) { $table->string('variant_name', 255) ->after('size') ->nullable(); - $table->integer('original_media_id') - ->unsigned() + $table->foreignIdFor(Media::class, 'original_media_id') + ->nullable() ->after('variant_name') - ->nullable(); - - $table->foreign('original_media_id', 'original_media_id') - ->references('id')->on('media') + ->constrained('media') ->nullOnDelete(); } ); diff --git a/migrations/2024_03_30_000000_add_alt_to_media.php b/migrations/2024_03_30_000000_add_alt_to_media.php new file mode 100644 index 00000000..d418837f --- /dev/null +++ b/migrations/2024_03_30_000000_add_alt_to_media.php @@ -0,0 +1,48 @@ +text('alt')->default(''); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'media', + function (Blueprint $table) { + $table->dropColumn('alt'); + } + ); + } + + /** + * {@inheritdoc} + */ + public function getConnection() + { + return config('mediable.connection_name', parent::getConnection()); + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..c8b99930 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 1 + paths: + - src + - tests diff --git a/phpunit.xml b/phpunit.xml index f2f4de41..803ac68d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,21 +1,15 @@ - - - ./src/ - - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" + cacheDirectory=".phpunit.cache" + backupStaticProperties="false" +> ./tests/Integration/ @@ -24,4 +18,9 @@ + + + ./src/ + + diff --git a/src/Commands/ImportMediaCommand.php b/src/Commands/ImportMediaCommand.php index ec058ef2..ad2f5071 100644 --- a/src/Commands/ImportMediaCommand.php +++ b/src/Commands/ImportMediaCommand.php @@ -31,33 +31,19 @@ class ImportMediaCommand extends Command */ protected $description = 'Create a media entity for each file on a disk'; - /** - * Filesystem Manager instance. - * @var FilesystemManager - */ - protected $filesystem; + protected FilesystemManager $filesystem; - /** - * Uploader instance. - * @var MediaUploader - */ - protected $uploader; + protected MediaUploader $uploader; /** * Various counters of files being modified. - * @var array */ - protected $counters = [ + protected array $counters = [ 'created' => 0, 'updated' => 0, 'skipped' => 0, ]; - /** - * Constructor. - * @param FilesystemManager $filesystem - * @param MediaUploader $uploader - */ public function __construct(FileSystemManager $filesystem, MediaUploader $uploader) { parent::__construct(); @@ -81,7 +67,6 @@ public function handle(): void $files = $this->listFiles($disk, $directory, $recursive); $existing_media = $this->makeModel() - ->newQuery() ->inDirectory($disk, $directory, $recursive) ->get(); diff --git a/src/Commands/PruneMediaCommand.php b/src/Commands/PruneMediaCommand.php index a16d469f..039af4cf 100644 --- a/src/Commands/PruneMediaCommand.php +++ b/src/Commands/PruneMediaCommand.php @@ -26,16 +26,8 @@ class PruneMediaCommand extends Command */ protected $description = 'Delete media records that do not correspond to a file on disk'; - /** - * Filesystem Manager instance. - * @var FilesystemManager - */ - protected $filesystem; + protected FilesystemManager $filesystem; - /** - * Constructor. - * @param FilesystemManager $filesystem - */ public function __construct(FileSystemManager $filesystem) { parent::__construct(); diff --git a/src/Exceptions/ImageManipulationException.php b/src/Exceptions/ImageManipulationException.php index fe761ab2..b86f41d9 100644 --- a/src/Exceptions/ImageManipulationException.php +++ b/src/Exceptions/ImageManipulationException.php @@ -28,6 +28,6 @@ public static function unknownOutputFormat(): self public static function fileExists(string $path): self { - return new static("A file already exists at `{$path}`."); + return new self("A file already exists at `{$path}`."); } } diff --git a/src/Exceptions/MediaMoveException.php b/src/Exceptions/MediaMoveException.php index c95a1f30..84f1b31e 100644 --- a/src/Exceptions/MediaMoveException.php +++ b/src/Exceptions/MediaMoveException.php @@ -9,21 +9,21 @@ class MediaMoveException extends Exception { public static function destinationExists(string $path): self { - return new static("Another file already exists at `{$path}`."); + return new self("Another file already exists at `{$path}`."); } public static function destinationExistsOnDisk(string $disk, string $path): self { - return new static("Another file already exists at `{$path}` on disk `{$disk}`."); + return new self("Another file already exists at `{$path}` on disk `{$disk}`."); } public static function fileNotFound(string $disk, string $path, Exception $previous = null): self { - return new static("File not found at `{$path}` on disk `{$disk}`.", 0, $previous); + return new self("File not found at `{$path}` on disk `{$disk}`.", 0, $previous); } public static function failedToCopy(string $from, string $to, Exception $previous = null): self { - return new static("Failed to copy file from `{$from}` to `{$to}`.", 0, $previous); + return new self("Failed to copy file from `{$from}` to `{$to}`.", 0, $previous); } } diff --git a/src/Exceptions/MediaUpload/ConfigurationException.php b/src/Exceptions/MediaUpload/ConfigurationException.php index be1ef5c5..7ffa949e 100644 --- a/src/Exceptions/MediaUpload/ConfigurationException.php +++ b/src/Exceptions/MediaUpload/ConfigurationException.php @@ -9,17 +9,17 @@ class ConfigurationException extends MediaUploadException { public static function cannotSetAdapter(string $class): self { - return new static("Could not set adapter of class `{$class}`. Must implement `\Plank\Mediable\SourceAdapters\SourceAdapterInterface`."); + return new self("Could not set adapter of class `{$class}`. Must implement `\Plank\Mediable\SourceAdapters\SourceAdapterInterface`."); } public static function cannotSetModel(string $class): self { - return new static("Could not set `{$class}` as Media model class. Must extend `\Plank\Mediable\Media`."); + return new self("Could not set `{$class}` as Media model class. Must extend `\Plank\Mediable\Media`."); } public static function noSourceProvided(): self { - return new static('No source provided for upload.'); + return new self('No source provided for upload.'); } public static function unrecognizedSource($source): self @@ -30,11 +30,26 @@ public static function unrecognizedSource($source): self $source = get_resource_type($source); } - return new static("Could not recognize source, `{$source}` provided."); + return new self("Could not recognize source, `{$source}` provided."); + } + + public static function invalidSource(string $message, \Throwable $original = null): self + { + return new self("Invalid source provided. {$message}", 0, $original); } public static function diskNotFound(string $disk): self { - return new static("Cannot find disk named `{$disk}`."); + return new self("Cannot find disk named `{$disk}`."); + } + + public static function cannotInferFilename(): self + { + return new self('No filename is provided and cannot infer filename from the provided source.'); + } + + public static function invalidOptimizer(string $optimizerClass): self + { + return new self("Invalid optimizer class `{$optimizerClass}`. Must implement `\Spatie\ImageOptimizer\Optimizer`."); } } diff --git a/src/Exceptions/MediaUpload/FileExistsException.php b/src/Exceptions/MediaUpload/FileExistsException.php index 6b3ddec1..4a0848e5 100644 --- a/src/Exceptions/MediaUpload/FileExistsException.php +++ b/src/Exceptions/MediaUpload/FileExistsException.php @@ -9,6 +9,6 @@ class FileExistsException extends MediaUploadException { public static function fileExists(string $path): self { - return new static("A file already exists at `{$path}`."); + return new self("A file already exists at `{$path}`."); } } diff --git a/src/Exceptions/MediaUpload/FileNotFoundException.php b/src/Exceptions/MediaUpload/FileNotFoundException.php index d20f8feb..275e73ea 100644 --- a/src/Exceptions/MediaUpload/FileNotFoundException.php +++ b/src/Exceptions/MediaUpload/FileNotFoundException.php @@ -7,8 +7,13 @@ class FileNotFoundException extends MediaUploadException { - public static function fileNotFound(string $path): self + public static function fileNotFound(string $path, \Throwable $original = null): self { - return new static("File `{$path}` does not exist."); + return new self("File `{$path}` does not exist.", 0, $original); + } + + public static function invalidDataUrl(): self + { + return new self('Invalid Data URL'); } } diff --git a/src/Exceptions/MediaUpload/FileNotSupportedException.php b/src/Exceptions/MediaUpload/FileNotSupportedException.php index 28c955ae..d7878909 100644 --- a/src/Exceptions/MediaUpload/FileNotSupportedException.php +++ b/src/Exceptions/MediaUpload/FileNotSupportedException.php @@ -9,32 +9,32 @@ class FileNotSupportedException extends MediaUploadException { public static function strictTypeMismatch(string $mime, string $ext): self { - return new static("File with mime of `{$mime}` not recognized for extension `{$ext}`."); + return new self("File with mime of `{$mime}` not recognized for extension `{$ext}`."); } public static function unrecognizedFileType(string $mime, string $ext): self { - return new static("File with mime of `{$mime}` and extension `{$ext}` is not recognized."); + return new self("File with mime of `{$mime}` and extension `{$ext}` is not recognized."); } public static function mimeRestricted(string $mime, array $allowed_mimes): self { $allowed = implode('`, `', $allowed_mimes); - return new static("Cannot upload file with MIME type `{$mime}`. Only the `{$allowed}` MIME type(s) are permitted."); + return new self("Cannot upload file with MIME type `{$mime}`. Only the `{$allowed}` MIME type(s) are permitted."); } public static function extensionRestricted(string $extension, array $allowed_extensions): self { $allowed = implode('`, `', $allowed_extensions); - return new static("Cannot upload file with extension `{$extension}`. Only the `{$allowed}` extension(s) are permitted."); + return new self("Cannot upload file with extension `{$extension}`. Only the `{$allowed}` extension(s) are permitted."); } public static function aggregateTypeRestricted(string $type, array $allowed_types): self { $allowed = implode('`, `', $allowed_types); - return new static("Cannot upload file of aggregate type `{$type}`. Only files of type(s) `{$allowed}` are permitted."); + return new self("Cannot upload file of aggregate type `{$type}`. Only files of type(s) `{$allowed}` are permitted."); } } diff --git a/src/Exceptions/MediaUpload/FileSizeException.php b/src/Exceptions/MediaUpload/FileSizeException.php index 8583df12..c5e40d3d 100644 --- a/src/Exceptions/MediaUpload/FileSizeException.php +++ b/src/Exceptions/MediaUpload/FileSizeException.php @@ -9,6 +9,6 @@ class FileSizeException extends MediaUploadException { public static function fileIsTooBig(int $size, int $max): self { - return new static("File is too big ({$size} bytes). Maximum upload size is {$max} bytes."); + return new self("File is too big ({$size} bytes). Maximum upload size is {$max} bytes."); } } diff --git a/src/Exceptions/MediaUpload/ForbiddenException.php b/src/Exceptions/MediaUpload/ForbiddenException.php index 69b65450..b8cabb9c 100644 --- a/src/Exceptions/MediaUpload/ForbiddenException.php +++ b/src/Exceptions/MediaUpload/ForbiddenException.php @@ -9,6 +9,6 @@ class ForbiddenException extends MediaUploadException { public static function diskNotAllowed(string $disk): self { - return new static("The disk `{$disk}` is not in the allowed disks for media."); + return new self("The disk `{$disk}` is not in the allowed disks for media."); } } diff --git a/src/Exceptions/MediaUpload/InvalidHashException.php b/src/Exceptions/MediaUpload/InvalidHashException.php new file mode 100644 index 00000000..45337b72 --- /dev/null +++ b/src/Exceptions/MediaUpload/InvalidHashException.php @@ -0,0 +1,13 @@ + [ ForbiddenException::class, @@ -50,10 +50,10 @@ trait HandlesMediaUploadExceptions /** * Transform a MediaUploadException into an HttpException. * - * @param \Exception $e - * @return \Exception + * @param \Throwable $e + * @return \Throwable */ - protected function transformMediaUploadException(Exception $e): Exception + protected function transformMediaUploadException(\Throwable $e): \Throwable { if ($e instanceof MediaUploadException) { $status_code = $this->getStatusCodeForMediaUploadException($e); diff --git a/src/Helpers/File.php b/src/Helpers/File.php index 38edb2eb..ede7dd44 100644 --- a/src/Helpers/File.php +++ b/src/Helpers/File.php @@ -53,7 +53,7 @@ public static function sanitizeFileName(string $file, string $language = null): $language = $language ?: App::currentLocale(); return trim( preg_replace( - '/[^a-zA-Z0-9-_.%]+/', + '/[^a-zA-Z0-9\-_.%]+/', '-', Str::ascii($file, $language) ), @@ -62,7 +62,7 @@ public static function sanitizeFileName(string $file, string $language = null): } /** - * Generate a human readable bytecount string. + * Generate a human-readable byte count string. * @param int $bytes * @param int $precision * @return string @@ -91,17 +91,7 @@ public static function readableSize(int $bytes, int $precision = 1): string */ public static function guessExtension(string $mimeType): ?string { - // use Symfony MimeTypes component if available (symfony/http-foundation v4.3+) - if (class_exists(MimeTypes::class)) { - return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null; - } - - // fall back to the older ExtensionGuesser class (deprecated since Symfony 4.3) - if (class_exists(ExtensionGuesser::class)) { - return ExtensionGuesser::getInstance()->guess($mimeType); - } - - return null; + return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null; } public static function joinPathComponents(string ...$components): string diff --git a/src/ImageManipulation.php b/src/ImageManipulation.php index 064981f6..b69afcec 100644 --- a/src/ImageManipulation.php +++ b/src/ImageManipulation.php @@ -4,6 +4,8 @@ use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; use Plank\Mediable\Helpers\File; +use Spatie\ImageOptimizer\Optimizer; +use Spatie\ImageOptimizer\OptimizerChain; class ImageManipulation { @@ -37,39 +39,39 @@ class ImageManipulation /** @var callable */ private $callback; - /** @var string|null */ - private $outputFormat; + private ?string $outputFormat = null; - /** @var int */ - private $outputQuality = 90; + private int $outputQuality = 90; - /** @var string|null */ - private $disk; + private ?string $disk = null; - /** @var string|null */ - private $directory; + private ?string $directory = null; - /** @var string|null */ - private $filename; + private ?string $filename = null; - /** @var bool */ - private $hashFilename = false; + private ?string $hashFilenameAlgo = null; - /** @var string */ - private $onDuplicateBehaviour = self::ON_DUPLICATE_INCREMENT; + private string $onDuplicateBehaviour = self::ON_DUPLICATE_INCREMENT; /** @var string|null */ - private $visibility; + private ?string $visibility = null; /** @var callable|null */ private $beforeSave; + private bool $shouldOptimize; + + /** @var array,string[]> */ + private array $optimizers; + public function __construct(callable $callback) { $this->callback = $callback; + $this->shouldOptimize = config('mediable.image_optimization.enabled', true); + $this->setOptimizers(config('mediable.image_optimization.optimizers', [])); } - public static function make(callable $callback) + public static function make(callable $callback): self { return new self($callback); } @@ -254,7 +256,7 @@ public function getDirectory(): ?string public function useFilename(string $filename): self { $this->filename = File::sanitizeFilename($filename); - $this->hashFilename = false; + $this->hashFilenameAlgo = null; return $this; } @@ -263,9 +265,9 @@ public function useFilename(string $filename): self * Indicates to the uploader to generate a filename using the file's MD5 hash. * @return $this */ - public function useHashForFilename(): self + public function useHashForFilename(string $algo = 'md5'): self { - $this->hashFilename = true; + $this->hashFilenameAlgo = $algo; $this->filename = null; return $this; @@ -278,25 +280,24 @@ public function useHashForFilename(): self public function useOriginalFilename(): self { $this->filename = null; - $this->hashFilename = false; + $this->hashFilenameAlgo = null; return $this; } - /** - * @return string|null - */ public function getFilename(): ?string { return $this->filename; } - /** - * @return bool - */ - public function usingHashForFilename(): bool + public function isUsingHashForFilename(): bool { - return $this->hashFilename; + return $this->hashFilenameAlgo !== null; + } + + public function getHashFilenameAlgo(): ?string + { + return $this->hashFilenameAlgo; } /** @@ -364,4 +365,59 @@ public function beforeSave(callable $beforeSave): self return $this; } + + /** + * Disable image optimization. + * @return $this + */ + public function noOptimization(): self + { + $this->shouldOptimize = false; + + return $this; + } + + /** + * Enable image optimization. + * @param array,string[]> $customOptimizers Override default optimizers. + * The array keys should be the fully qualified class names of the optimizers to use. + * The array values should be arrays of command line arguments to pass to the optimizer. + * DO NOT PASS UNTRUSTED USER INPUT AS COMMAND LINE ARGUMENTS + * @return $this + * @throws ConfigurationException + */ + public function optimize(?array $customOptimizers = null): self + { + if ($customOptimizers !== null) { + $this->setOptimizers($customOptimizers); + } + $this->shouldOptimize = true; + + return $this; + } + + public function shouldOptimize(): bool + { + return $this->shouldOptimize && !empty($this->optimizers); + } + + public function getOptimizerChain(): OptimizerChain + { + $chain = new OptimizerChain(); + foreach ($this->optimizers as $optimizerClass => $args) { + $optimizer = new $optimizerClass($args); + $chain->addOptimizer($optimizer); + } + return $chain; + } + + private function setOptimizers(array $customOptimizers): void + { + foreach ($customOptimizers as $optimizerClass => $args) { + if (!is_a($optimizerClass, Optimizer::class, true)) { + throw ConfigurationException::invalidOptimizer($optimizerClass); + } + } + $this->optimizers = $customOptimizers; + } } diff --git a/src/ImageManipulator.php b/src/ImageManipulator.php index 21651127..e2bf080f 100644 --- a/src/ImageManipulator.php +++ b/src/ImageManipulator.php @@ -2,11 +2,17 @@ namespace Plank\Mediable; +use GuzzleHttp\Psr7\Utils; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Collection; +use Intervention\Image\Commands\StreamCommand; +use Intervention\Image\Image; use Intervention\Image\ImageManager; use Plank\Mediable\Exceptions\ImageManipulationException; +use Plank\Mediable\SourceAdapters\SourceAdapterInterface; +use Plank\Mediable\SourceAdapters\StreamAdapter; use Psr\Http\Message\StreamInterface; +use Spatie\ImageOptimizer\OptimizerChain; class ImageManipulator { @@ -24,10 +30,16 @@ class ImageManipulator */ private $filesystem; - public function __construct(ImageManager $imageManager, FilesystemManager $filesystem) - { + private ImageOptimizer $imageOptimizer; + + public function __construct( + ImageManager $imageManager, + FilesystemManager $filesystem, + ImageOptimizer $imageOptimizer + ) { $this->imageManager = $imageManager; $this->filesystem = $filesystem; + $this->imageOptimizer = $imageOptimizer; } public function defineVariant( @@ -87,7 +99,6 @@ public function getVariantNamesByTag(string $tag): array * @param bool $forceRecreate * @return Media * @throws ImageManipulationException - * @throws \Illuminate\Contracts\Filesystem\FileExistsException */ public function createImageVariant( Media $media, @@ -118,16 +129,30 @@ public function createImageVariant( $manipulation = $this->getVariantDefinition($variantName); $outputFormat = $this->determineOutputFormat($manipulation, $media); - $image = $this->imageManager->make($media->contents()); + if (method_exists($this->imageManager, 'read')) { + // Intervention Image >=3.0 + $image = $this->imageManager->read($media->contents()); + } else { + // Intervention Image <3.0 + $image = $this->imageManager->make($media->contents()); + } $callback = $manipulation->getCallback(); $callback($image, $media); - $outputStream = $image->stream( + $outputStream = $this->imageToStream( + $image, $outputFormat, $manipulation->getOutputQuality() ); + if ($manipulation->shouldOptimize()) { + $outputStream = $this->imageOptimizer->optimizeImage( + $outputStream, + $manipulation->getOptimizerChain() + ); + } + $variant->variant_name = $variantName; $variant->original_media_id = $media->isOriginal() ? $media->getKey() @@ -180,6 +205,51 @@ public function createImageVariant( return $variant; } + /** + * @param Media $media + * @param SourceAdapterInterface $source + * @param ImageManipulation $manipulation + * @return StreamAdapter + * @throws ImageManipulationException + */ + public function manipulateUpload( + Media $media, + SourceAdapterInterface $source, + ImageManipulation $manipulation + ): StreamAdapter { + $outputFormat = $this->determineOutputFormat($manipulation, $media); + if (method_exists($this->imageManager, 'read')) { + // Intervention Image >=3.0 + $image = $this->imageManager->read($source->getStream()->getContents()); + } else { + // Intervention Image <3.0 + $image = $this->imageManager->make($source->getStream()->getContents()); + } + + $callback = $manipulation->getCallback(); + $callback($image, $media); + + $outputStream = $this->imageToStream( + $image, + $outputFormat, + $manipulation->getOutputQuality() + ); + + if ($manipulation->shouldOptimize()) { + $outputStream = $this->imageOptimizer->optimizeImage( + $outputStream, + $manipulation->getOptimizerChain() + ); + } + + $media->extension = $outputFormat; + $media->mime_type = $this->getMimeTypeForOutputFormat($outputFormat); + $media->aggregate_type = Media::TYPE_IMAGE; + $media->size = $outputStream->getSize(); + + return new StreamAdapter($outputStream); + } + private function getMimeTypeForOutputFormat(string $outputFormat): string { return ImageManipulation::MIME_TYPE_MAP[$outputFormat]; @@ -231,24 +301,27 @@ public function determineFilename( return $filename; } - if ($manipulation->usingHashForFilename()) { - return $this->getHashFromStream($stream); + if ($manipulation->isUsingHashForFilename()) { + return $this->getHashFromStream( + $stream, + $manipulation->getHashFilenameAlgo() ?? 'md5' + ); } return sprintf('%s-%s', $originalMedia->filename, $variant->variant_name); } - public function validateMedia(Media $media) + public function validateMedia(Media $media): void { if ($media->aggregate_type != Media::TYPE_IMAGE) { throw ImageManipulationException::invalidMediaType($media->aggregate_type); } } - private function getHashFromStream(StreamInterface $stream): string + private function getHashFromStream(StreamInterface $stream, string $algo): string { $stream->rewind(); - $hash = hash_init('md5'); - while ($chunk = $stream->read(64)) { + $hash = hash_init($algo); + while ($chunk = $stream->read(2048)) { hash_update($hash, $chunk); } $filename = hash_final($hash); @@ -270,7 +343,7 @@ private function checkForDuplicates( return; } - if (!$this->filesystem->disk($variant->disk)->has($variant->getDiskPath())) { + if (!$this->filesystem->disk($variant->disk)->exists($variant->getDiskPath())) { // no conflict, carry on return; } @@ -302,8 +375,32 @@ private function generateUniqueFilename(Media $model): string } $path = "{$model->directory}/{$filename}.{$model->extension}"; ++$counter; - } while ($storage->has($path)); + } while ($storage->exists($path)); return $filename; } + + private function imageToStream( + Image $image, + string $outputFormat, + int $outputQuality + ) { + if (class_exists(StreamCommand::class)) { + // Intervention Image <3.0 + return $image->stream( + $outputFormat, + $outputQuality + ); + } + + $formatted = match ($outputFormat) { + ImageManipulation::FORMAT_JPG => $image->toJpeg($outputQuality), + ImageManipulation::FORMAT_PNG => $image->toPng(), + ImageManipulation::FORMAT_GIF => $image->toGif(), + ImageManipulation::FORMAT_WEBP => $image->toBitmap(), + ImageManipulation::FORMAT_TIFF => $image->toTiff($outputQuality), + default => throw ImageManipulationException::unknownOutputFormat(), + }; + return Utils::streamFor($formatted->toFilePointer()); + } } diff --git a/src/ImageOptimizer.php b/src/ImageOptimizer.php new file mode 100644 index 00000000..578cf8b9 --- /dev/null +++ b/src/ImageOptimizer.php @@ -0,0 +1,32 @@ +getTmpFile(); + $tmpStream = Utils::streamFor(Utils::tryFopen($tmpPath, 'wb')); + Utils::copyToStream($imageStream, $tmpStream); + $optimizerChain->optimize($tmpPath); + // open a separate stream to detect the changes made by the optimizers + return Utils::streamFor(Utils::tryFopen($tmpPath, 'rb')); + } + + private function getTmpFile(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'mediable-'); + if ($tmpFile === false) { + throw new \RuntimeException( + 'Could not create temporary file. The system temp directory may not be writable.' + ); + } + return $tmpFile; + } +} diff --git a/src/Jobs/CreateImageVariants.php b/src/Jobs/CreateImageVariants.php index 05b4015e..8499f447 100644 --- a/src/Jobs/CreateImageVariants.php +++ b/src/Jobs/CreateImageVariants.php @@ -19,20 +19,20 @@ class CreateImageVariants implements ShouldQueue /** * @var string[] */ - private $variantNames; + private array $variantNames; /** - * @var Collection|Media[] + * @var Collection */ - private $models; + private Collection $models; /** * @var bool */ - private $forceRecreate; + private bool $forceRecreate; /** * CreateImageVariants constructor. - * @param Media|Collection|Media[] $model + * @param Media|Collection|Media[] $models * @param string|string[] $variantNames * @throws ImageManipulationException */ @@ -47,7 +47,7 @@ public function __construct($models, $variantNames, bool $forceRecreate = false) $this->forceRecreate = $forceRecreate; } - public function handle() + public function handle(): void { foreach ($this->getModels() as $model) { foreach ($this->getVariantNames() as $variantName) { @@ -77,7 +77,7 @@ public function getModels(): Collection } /** - * @param Media $model + * @param Collection $models * @param array $variantNames * @throws ImageManipulationException */ @@ -107,7 +107,7 @@ public function getForceRecreate(): bool /** * @param Media|Collection|Media[] $models - * @return bool + * @return Collection */ private function collect($models): Collection { diff --git a/src/Media.php b/src/Media.php index bd0c6367..2abd9f8c 100644 --- a/src/Media.php +++ b/src/Media.php @@ -35,6 +35,7 @@ * @property string|null $variant_name * @property int|string|null $original_media_id * @property int|null $size + * @property string|null $alt * @property Carbon $created_at * @property Carbon $updated_at * @property Pivot $pivot @@ -76,7 +77,8 @@ class Media extends Model 'mime_type', 'aggregate_type', 'variant_name', - 'original_media_id' + 'original_media_id', + 'alt', ]; protected $casts = [ @@ -214,6 +216,15 @@ public function getBasenameAttribute(): string return $this->filename . '.' . $this->extension; } + /** + * Retrieve the file url. + * @return string + */ + public function getUrlAttribute(): string + { + return $this->getUrl(); + } + /** * Query scope for to find media in a particular directory. * @param Builder $q @@ -235,7 +246,7 @@ public function scopeInDirectory(Builder $q, string $disk, string $directory, bo /** * Query scope for finding media in a particular directory or one of its subdirectories. - * @param Builder|Media $q + * @param Builder $q * @param string $disk Filesystem disk to search in * @param string $directory Path relative to disk * @return void @@ -290,7 +301,7 @@ public function scopeWhereIsOriginal(Builder $q): void $q->whereNull('original_media_id'); } - public function scopeWhereIsVariant(Builder $q, string $variant_name = null) + public function scopeWhereIsVariant(Builder $q, string $variant_name = null): void { $q->whereNotNull('original_media_id'); if ($variant_name) { @@ -361,7 +372,7 @@ public function getTemporaryUrl(\DateTimeInterface $expiry): string */ public function fileExists(): bool { - return $this->storage()->has($this->getDiskPath()); + return $this->storage()->exists($this->getDiskPath()); } /** @@ -396,13 +407,10 @@ public function contents(): string * Get a read stream to the file * @return StreamInterface */ - public function stream() + public function stream(): StreamInterface { $stream = $this->storage()->readStream($this->getDiskPath()); - if (method_exists(Utils::class, 'streamFor')) { - return Utils::streamFor($stream); - } - return \GuzzleHttp\Psr7\stream_for($stream); + return Utils::streamFor($stream); } /** @@ -442,12 +450,17 @@ public function makeOriginal(): self return $this; } + /** + * @param Media|string|int $media + * @param string $variantName + * @return $this + */ public function makeVariantOf($media, string $variantName): self { - if (!$media instanceof static) { + if (!$media instanceof self) { $media = $this->newQuery()->findOrFail($media); } - + /** @var Media $media */ $this->variant_name = $variantName; $this->original_media_id = $media->isOriginal() ? $media->getKey() @@ -500,7 +513,7 @@ public function copyTo(string $destination, string $filename = null): self * * Will invoke the `save()` method on the model after the associated file has been moved to prevent synchronization errors * @param string $disk the disk to move the file to - * @param string $directory directory relative to disk root + * @param string $destination directory relative to disk root * @param string $filename filename. Do not include extension * @return void * @throws MediaMoveException If attempting to change the file extension or a file with the same name already exists at the destination @@ -520,9 +533,8 @@ public function moveToDisk( * * This method creates a new Media object as well as duplicates the associated file on the disk. * - * @param Media $media The media to copy from * @param string $disk the disk to copy the file to - * @param string $directory directory relative to disk root + * @param string $destination directory relative to disk root * @param string $filename optional filename. Do not include extension * * @return Media @@ -546,14 +558,16 @@ protected function getMediaMover(): MediaMover protected function handleMediaDeletion(): void { // optionally detach mediable relationships on soft delete - if (static::hasGlobalScope(SoftDeletingScope::class) && !$this->forceDeleting) { + if (static::hasGlobalScope(SoftDeletingScope::class) + && (!property_exists($this, 'forceDeleting') || !$this->forceDeleting) + ) { if (config('mediable.detach_on_soft_delete')) { $this->newBaseQueryBuilder() ->from(config('mediable.mediables_table', 'mediables')) ->where('media_id', $this->getKey()) ->delete(); } - } elseif ($this->storage()->has($this->getDiskPath())) { + } elseif ($this->storage()->exists($this->getDiskPath())) { // unlink associated file on delete $this->storage()->delete($this->getDiskPath()); } diff --git a/src/MediaMover.php b/src/MediaMover.php index b6b75dd7..544d112b 100644 --- a/src/MediaMover.php +++ b/src/MediaMover.php @@ -13,15 +13,8 @@ */ class MediaMover { - /** - * @var FilesystemManager - */ - protected $filesystem; + protected FilesystemManager $filesystem; - /** - * Constructor. - * @param FilesystemManager $filesystem - */ public function __construct(FilesystemManager $filesystem) { $this->filesystem = $filesystem; @@ -45,7 +38,7 @@ public function move(Media $media, string $directory, string $filename = null): $directory = File::sanitizePath($directory); $targetPath = $directory . '/' . $filename . '.' . $media->extension; - if ($storage->has($targetPath)) { + if ($storage->exists($targetPath)) { throw MediaMoveException::destinationExists($targetPath); } @@ -87,7 +80,7 @@ public function moveToDisk( $directory = File::sanitizePath($directory); $targetPath = $directory . '/' . $filename . '.' . $media->extension; - if ($targetStorage->has($targetPath)) { + if ($targetStorage->exists($targetPath)) { throw MediaMoveException::destinationExistsOnDisk($disk, $targetPath); } @@ -129,7 +122,7 @@ public function copyTo(Media $media, string $directory, string $filename = null) $targetPath = $directory . '/' . $filename . '.' . $media->extension; - if ($storage->has($targetPath)) { + if ($storage->exists($targetPath)) { throw MediaMoveException::destinationExists($targetPath); } @@ -181,7 +174,7 @@ public function copyToDisk( $directory = File::sanitizePath($directory); $targetPath = $directory . '/' . $filename . '.' . $media->extension; - if ($targetStorage->has($targetPath)) { + if ($targetStorage->exists($targetPath)) { throw MediaMoveException::destinationExistsOnDisk($disk, $targetPath); } diff --git a/src/MediaUploader.php b/src/MediaUploader.php index 48844ea8..d96b6264 100644 --- a/src/MediaUploader.php +++ b/src/MediaUploader.php @@ -12,9 +12,11 @@ use Plank\Mediable\Exceptions\MediaUpload\FileNotSupportedException; use Plank\Mediable\Exceptions\MediaUpload\FileSizeException; use Plank\Mediable\Exceptions\MediaUpload\ForbiddenException; +use Plank\Mediable\Exceptions\MediaUpload\InvalidHashException; use Plank\Mediable\Helpers\File; use Plank\Mediable\SourceAdapters\RawContentAdapter; use Plank\Mediable\SourceAdapters\SourceAdapterFactory; +use Plank\Mediable\SourceAdapters\SourceAdapterInterface; /** * Media Uploader. @@ -29,57 +31,37 @@ class MediaUploader const ON_DUPLICATE_REPLACE = 'replace'; const ON_DUPLICATE_REPLACE_WITH_VARIANTS = 'replace_with_variants'; - /** - * @var FileSystemManager - */ - private $filesystem; + private FileSystemManager $filesystem; - /** - * @var SourceAdapterFactory - */ - private $factory; + private SourceAdapterFactory $factory; - /** - * Mediable configurations. - * @var array - */ - private $config; + private ImageManipulator $imageManipulator; - /** - * Source adapter. - * @var \Plank\Mediable\SourceAdapters\SourceAdapterInterface - */ - private $source; + private array $config; - /** - * Name of the filesystem disk. - * @var string - */ - private $disk; + private SourceAdapterInterface $source; + + private ?string $disk = null; /** * Path relative to the filesystem disk root. - * @var string */ - private $directory = ''; + private ?string $directory = null; /** * Name of the new file. - * @var string|null */ - private $filename = null; + private ?string $filename = null; /** * If true the contents hash of the source will be used as the filename. - * @var bool */ - private $hashFilename = false; + private ?string $hashFilenameAlgo = null; /** * Visibility for the new file - * @var string */ - private $visibility = Filesystem::VISIBILITY_PUBLIC; + private ?string $visibility = null; /** * Callable allowing to alter the model before save. @@ -89,9 +71,12 @@ class MediaUploader /** * Additional options to pass to the filesystem while uploading - * @var array */ - private $options = []; + private array $options = []; + + private ?string $alt = null; + + private array $expectedHashes = []; /** * Constructor. @@ -99,10 +84,15 @@ class MediaUploader * @param SourceAdapterFactory $factory * @param array|null $config */ - public function __construct(FileSystemManager $filesystem, SourceAdapterFactory $factory, array $config = null) - { + public function __construct( + FileSystemManager $filesystem, + SourceAdapterFactory $factory, + ImageManipulator $imageManipulator, + array $config = null + ) { $this->filesystem = $filesystem; $this->factory = $factory; + $this->imageManipulator = $imageManipulator; $this->config = $config ?: config('mediable', []); } @@ -114,7 +104,7 @@ public function __construct(FileSystemManager $filesystem, SourceAdapterFactory * @return $this * @throws ConfigurationException */ - public function fromSource($source): self + public function fromSource(mixed $source): self { $this->source = $this->factory->create($source); @@ -184,18 +174,25 @@ public function toDirectory(string $directory): self public function useFilename(string $filename): self { $this->filename = File::sanitizeFilename($filename); - $this->hashFilename = false; + $this->hashFilenameAlgo = null; return $this; } + public function withAltAttribute(string $alt): self + { + $this->alt = $alt; + return $this; + } + /** * Indicates to the uploader to generate a filename using the file's MD5 hash. + * @param string $algo any hashing algorithm supported by PHP's hash() function * @return $this */ - public function useHashForFilename(): self + public function useHashForFilename(string $algo = 'md5'): self { - $this->hashFilename = true; + $this->hashFilenameAlgo = $algo; $this->filename = null; return $this; @@ -208,7 +205,7 @@ public function useHashForFilename(): self public function useOriginalFilename(): self { $this->filename = null; - $this->hashFilename = false; + $this->hashFilenameAlgo = null; return $this; } @@ -236,7 +233,7 @@ public function setModelClass(string $class): self */ public function setMaximumSize(int $size): self { - $this->config['max_size'] = (int)$size; + $this->config['max_size'] = $size; return $this; } @@ -248,7 +245,7 @@ public function setMaximumSize(int $size): self */ public function setOnDuplicateBehavior(string $behavior): self { - $this->config['on_duplicate'] = (string)$behavior; + $this->config['on_duplicate'] = $behavior; return $this; } @@ -374,6 +371,30 @@ public function setAllowedMimeTypes(array $allowedMimes): self return $this; } + /** + * Prefer the MIME type provided by the client, if any, over the inferred MIME type. + * Depending on the source, this may not be accurate. + * @return $this + */ + public function preferClientMimeType(): self + { + $this->config['prefer_client_mime_type'] = true; + + return $this; + } + + /** + * Prefer the MIME type inferred by the contents of the file, if available, + * over the MIME type provided by the client. + * @return $this + */ + public function preferInferredMimeType(): self + { + $this->config['prefer_client_mime_type'] = false; + + return $this; + } + /** * Set a list of file extensions that the source file must be restricted to. * @param string[] $allowedExtensions @@ -398,6 +419,20 @@ public function setAllowedAggregateTypes(array $allowedTypes): self return $this; } + /** + * Verify the MD5 hash of the file contents matches an expected value. + * The upload process will throw an InvalidHashException if the hash of the + * uploaded file does not match the provided value. + * @param string|null $expectedHash set to null to disable hash validation + * @param string $algo any hashing algorithm supported by PHP's hash() function + * @return $this + */ + public function validateHash(?string $expectedHash, string $algo = 'md5'): self + { + $this->expectedHashes[$algo] = $expectedHash; + return $this; + } + /** * Make the resulting file public (default behaviour) * @return $this @@ -418,6 +453,38 @@ public function makePrivate(): self return $this; } + public function getVisibility(): string + { + if ($this->visibility) { + return $this->visibility; + } + + return config( + 'filesystems.disks.'.$this->disk.'.visibility', + Filesystem::VISIBILITY_PUBLIC + ); + } + + /** + * Apply an image manipulation to the uploaded image. + * + * This will modify the image before saving it to disk. + * The original image will not be preserved. + * + * Note this will manipulate the image as part of the upload process, which may be slow. + * @param string|ImageManipulation $imageManipulation Either a defined ImageManipulation variant name + * or an ImageManipulation instance + * @return $this + */ + public function applyImageManipulation($imageManipulation): self + { + if (is_string($imageManipulation)) { + $imageManipulation = $this->imageManipulator->getVariantDefinition($imageManipulation); + } + $this->config['image_manipulation'] = $imageManipulation; + return $this; + } + /** * Additional options to pass to the filesystem when uploading * @param array $options @@ -519,6 +586,7 @@ public function possibleAggregateTypesForExtension(string $extension): array * @throws FileNotFoundException * @throws FileNotSupportedException * @throws FileSizeException + * @throws InvalidHashException */ public function upload(): Media { @@ -526,6 +594,8 @@ public function upload(): Media $model = $this->populateModel($this->makeModel()); + $this->manipulateImage($model); + if (is_callable($this->before_save)) { call_user_func($this->before_save, $model, $this->source); } @@ -599,15 +669,22 @@ public function replace(Media $media): Media */ private function populateModel(Media $model): Media { - $model->size = $this->verifyFileSize($this->source->size()); - $model->mime_type = $this->verifyMimeType($this->source->mimeType()); - $model->extension = $this->verifyExtension($this->source->extension()); + $model->size = $this->verifyFileSize($this->source->size() ?? 0); + $model->mime_type = $this->verifyMimeType($this->selectMimeType()); + $model->extension = $this->verifyExtension( + $this->source->extension() + ?? File::guessExtension($model->mime_type) + ); $model->aggregate_type = $this->inferAggregateType($model->mime_type, $model->extension); $model->disk = $this->disk ?: $this->config['default_disk']; $model->directory = $this->directory; $model->filename = $this->generateFilename(); + if ($this->alt) { + $model->alt = $this->alt; + } + return $model; } @@ -670,7 +747,7 @@ public function import(string $disk, string $directory, string $filename, string $model->filename = $filename; $model->extension = $this->verifyExtension($extension, false); - if (!$storage->has($model->getDiskPath())) { + if (!$storage->exists($model->getDiskPath())) { throw FileNotFoundException::fileNotFound($model->getDiskPath()); } @@ -680,7 +757,13 @@ public function import(string $disk, string $directory, string $filename, string $model->aggregate_type = $this->inferAggregateType($model->mime_type, $model->extension); $model->size = $this->verifyFileSize($storage->size($model->getDiskPath())); - $storage->setVisibility($model->getDiskPath(), $this->visibility); + if ($this->visibility) { + $storage->setVisibility($model->getDiskPath(), $this->visibility); + } + + if ($this->alt) { + $model->alt = $this->alt; + } if (is_callable($this->before_save)) { call_user_func($this->before_save, $model, $this->source); @@ -710,6 +793,10 @@ public function update(Media $media): bool ); $media->aggregate_type = $this->inferAggregateType($media->mime_type, $media->extension); + if ($this->alt) { + $media->alt = $this->alt; + } + if ($dirty = $media->isDirty()) { $media->save(); } @@ -729,9 +816,15 @@ public function update(Media $media): bool public function verifyFile(): void { $this->verifySource(); - $this->verifyFileSize($this->source->size()); - $this->verifyMimeType($this->source->mimeType()); - $this->verifyExtension($this->source->extension()); + $this->verifyFileSize($this->source->size() ?? 0); + $mimeType = $this->verifyMimeType( + $this->selectMimeType() + ); + $this->verifyExtension( + $this->source->extension() ?? File::guessExtension($mimeType) + ); + + $this->verifyHashes(); } /** @@ -776,21 +869,29 @@ private function verifySource(): void if (empty($this->source)) { throw ConfigurationException::noSourceProvided(); } - if (!$this->source->valid()) { - throw FileNotFoundException::fileNotFound($this->source->path()); - } } private function inferMimeType(Filesystem $filesystem, string $path): string { + $mimeType = null; try { - $mime = $filesystem->mimeType($path); + if (method_exists($filesystem, 'mimeType')) { + $mimeType = $filesystem->mimeType($path); + } } catch (UnableToRetrieveMetadata $e) { // previous versions of flysystem would default to octet-stream when // the file was unrecognized. Maintain the behaviour for now return 'application/octet-stream'; } - return $mime ?: 'application/octet-stream'; + return $mimeType ?: 'application/octet-stream'; + } + + private function selectMimeType(): string + { + if ($this->config['prefer_client_mime_type'] ?? false) { + return $this->source->clientMimeType() ?? $this->source->mimeType(); + } + return $this->source->mimeType(); } /** @@ -813,6 +914,7 @@ private function verifyMimeType(string $mimeType): string /** * Ensure that the file's extension is allowed. * @param string $extension + * @param bool $toLower * @return string * @throws FileNotSupportedException If the file extension is not allowed */ @@ -843,6 +945,24 @@ private function verifyFileSize(int $size): int return $size; } + private function verifyHashes(): void + { + foreach ($this->expectedHashes as $algo => $expectedHash) { + if ($expectedHash === null) { + return; + } + + $actualHash = $this->source->hash($algo); + if ($actualHash !== $expectedHash) { + throw InvalidHashException::hashMismatch( + $algo, + $expectedHash, + $actualHash + ); + } + } + } + /** * Verify that the intended destination is available and handle any duplications. * @param Media $model @@ -854,7 +974,7 @@ private function verifyDestination(Media $model): void { $storage = $this->filesystem->disk($model->disk); - if ($storage->has($model->getDiskPath())) { + if ($storage->exists($model->getDiskPath())) { $this->handleDuplicate($model); } } @@ -900,6 +1020,7 @@ private function handleDuplicate(Media $model): Media /** * Delete the media that previously existed at a destination. * @param Media $model + * @param bool $withVariants * @return void */ private function deleteExistingMedia(Media $model, bool $withVariants = false): void @@ -947,7 +1068,7 @@ private function generateUniqueFilename(Media $model): string } $path = "{$model->directory}/{$filename}.{$model->extension}"; ++$counter; - } while ($storage->has($path)); + } while ($storage->exists($path)); return $filename; } @@ -962,59 +1083,55 @@ private function generateFilename(): string return $this->filename; } - if ($this->hashFilename) { - return $this->generateHash(); + if ($this->hashFilenameAlgo) { + return $this->source->hash($this->hashFilenameAlgo); } - return File::sanitizeFileName($this->source->filename()); - } + $filename = $this->source->filename(); - /** - * Calculate hash of source contents. - * @return string - */ - private function generateHash(): string - { - $ctx = hash_init('md5'); - - // We don't need to read the file contents if the source has a path - if ($this->source->path()) { - hash_update_file($ctx, $this->source->path()); - } else { - hash_update($ctx, $this->source->contents()); + if ($filename === null) { + ConfigurationException::cannotInferFilename(); } - return hash_final($ctx); + return File::sanitizeFileName($filename); } - - private function writeToDisk(Media $model): void { - $stream = $this->source->getStreamResource(); - - if (!is_resource($stream)) { - $stream = $this->source->contents(); - }; - $this->filesystem->disk($model->disk) ->put( $model->getDiskPath(), - $stream, + $this->source->getStream(), $this->getOptions() ); - - if (is_resource($stream)) { - fclose($stream); - } } public function getOptions(): array { $options = $this->options; if (!isset($options['visibility'])) { - $options['visibility'] = $this->visibility; + $options['visibility'] = $this->getVisibility(); } return $options; } + + /** + * @param Media $model + * @return void + * @throws Exceptions\ImageManipulationException + */ + public function manipulateImage(Media $model): void + { + if (empty($this->config['image_manipulation']) + || $model->aggregate_type !== Media::TYPE_IMAGE + ) { + return; + } + $manipulation = $this->config['image_manipulation']; + $this->source = $this->imageManipulator->manipulateUpload( + $model, + $this->source, + $manipulation + ); + } } diff --git a/src/Mediable.php b/src/Mediable.php index d015b195..ac9dd1a1 100644 --- a/src/Mediable.php +++ b/src/Mediable.php @@ -31,7 +31,7 @@ trait Mediable * List of media tags that have been modified since last load. * @var string[] */ - private $mediaDirtyTags = []; + private array $mediaDirtyTags = []; /** * Boot the Mediable trait. @@ -100,7 +100,7 @@ public function scopeWhereHasMedia(Builder $q, $tags = [], bool $matchAll = fals * @param string|string[] $tags * @return void */ - public function scopeWhereHasMediaMatchAll(Builder $q, array $tags): void + public function scopeWhereHasMediaMatchAll(Builder $q, $tags): void { $this->scopeWhereHasMedia($q, $tags, true); } @@ -108,7 +108,7 @@ public function scopeWhereHasMediaMatchAll(Builder $q, array $tags): void /** * Query scope to eager load attached media. * - * @param Builder|Mediable $q + * @param Builder $q * @param string|string[] $tags If one or more tags are specified, only media attached to those tags will be loaded. * @param bool $matchAll Only load media matching all provided tags * @param bool $withVariants If true, also load the variants and/or originalMedia relation of each Media @@ -434,7 +434,7 @@ public function firstMedia($tags, bool $matchAll = false): ?Media * @see \Plank\Mediable\Mediable::getMedia() * @return Media|null */ - public function lastMedia($tags, $matchAll = false): ?Media + public function lastMedia($tags, bool $matchAll = false): ?Media { return $this->getMedia($tags, $matchAll)->last(); } @@ -565,7 +565,9 @@ protected function addMatchAllToEagerLoadQuery(MorphToMany $q, $tags = []): void protected function handleMediableDeletion(): void { // only cascade soft deletes when configured - if (static::hasGlobalScope(SoftDeletingScope::class) && !$this->forceDeleting) { + if (static::hasGlobalScope(SoftDeletingScope::class) + && (!property_exists($this, 'forceDeleting') || !$this->forceDeleting) + ) { if (config('mediable.detach_on_soft_delete')) { $this->media()->detach(); } diff --git a/src/MediableCollection.php b/src/MediableCollection.php index a31f46bb..36c08b97 100644 --- a/src/MediableCollection.php +++ b/src/MediableCollection.php @@ -11,6 +11,10 @@ /** * Collection of Mediable Models. + * + * @template TKey of array-key + * @template TMedia of Model&MediableInterface + * @extends Collection */ class MediableCollection extends Collection { @@ -43,7 +47,9 @@ public function loadMedia( if ($matchAll) { $closure = function (MorphToMany $q) use ($tags, $withVariants) { - $this->addMatchAllToEagerLoadQuery($q, $tags); + if (method_exists($this, 'addMatchAllToEagerLoadQuery')) { + $this->addMatchAllToEagerLoadQuery($q, $tags); + } if ($withVariants) { $q->with(['originalMedia.variants', 'variants']); @@ -117,7 +123,7 @@ public function delete(): void $classes = []; $this->each( - function (Model $item) use ($query, $relation, &$classes) { + function (Model $item) use (&$classes) { // collect list of ids of each class in case not all // items belong to the same class $classes[get_class($item)][] = $item->getKey(); @@ -126,6 +132,10 @@ function (Model $item) use ($query, $relation, &$classes) { // delete each item by class collect($classes)->each( + /** + * @param array $ids + * @param class-string $class + */ function (array $ids, string $class) use ($query, $relation) { // select pivots matching each item for deletion $query->orWhere( @@ -138,7 +148,7 @@ function (Builder $q) use ($class, $ids, $relation) { } ); - $class::whereIn((new $class)->getKeyName(), $ids)->delete(); + $class::query()->whereIn((new $class)->getKeyName(), $ids)->delete(); } ); diff --git a/src/MediableInterface.php b/src/MediableInterface.php new file mode 100644 index 00000000..9335ad9c --- /dev/null +++ b/src/MediableInterface.php @@ -0,0 +1,175 @@ + $media + * @method static Builder withMedia($tags = [], bool $matchAll = false, bool $withVariants = false) + * @method static Builder withMediaAndVariants($tags = [], bool $matchAll = false) + * @method static Builder withMediaMatchAll($tags = [], bool $withVariants = false) + * @method static Builder withMediaAndVariantsMatchAll($tags = []) + * @method static Builder whereHasMedia($tags = [], bool $matchAll = false) + * @method static Builder whereHasMediaMatchAll($tags) + */ +interface MediableInterface +{ + public function media(): MorphToMany; + + /** + * @param Builder $q + * @param string|string[] $tags + * @param bool $matchAll + * @return void + */ + public function scopeWhereHasMedia( + Builder $q, + $tags = [], + bool $matchAll = false + ): void; + + public function scopeWhereHasMediaMatchAll(Builder $q, array $tags): void; + + /** + * @param Builder $q + * @param string|string[] $tags + * @param bool $matchAll + * @param bool $withVariants + * @return mixed + */ + public function scopeWithMedia( + Builder $q, + $tags = [], + bool $matchAll = false, + bool $withVariants = false + ); + + /** + * @param Builder $q + * @param string|string[] $tags + * @param bool $matchAll + * @return mixed + */ + public function scopeWithMediaAndVariants( + Builder $q, + $tags = [], + bool $matchAll = false + ); + + /** + * @param Builder $q + * @param string|string[]$tags + * @param bool $withVariants + * @return mixed + */ + public function scopeWithMediaMatchAll( + Builder $q, + $tags = [], + bool $withVariants = false + ); + + /** + * @param Builder $q + * @param string|string[] $tags + * @return void + */ + public function scopeWithMediaAndVariantsMatchAll(Builder $q, $tags = []): void; + + public function loadMedia(); + + /** + * @param string|string[] $tags + * @param bool $matchAll + * @return self + */ + public function loadMediaWithVariants($tags = [], bool $matchAll = false): self; + + /** + * @param string|string[] $tags + * @param bool $withVariants + * @return self + */ + public function loadMediaMatchAll($tags = [], bool $withVariants = false): self; + + /** + * @param string|string[] $tags + * @return self + */ + public function loadMediaWithVariantsMatchAll($tags = []): self; + + /** + * @param string|int|int[]|Media|Collection $media + * @param string|string[] $tags + * @return void + */ + public function attachMedia($media, $tags): void; + + /** + * @param string|int|int[]|Media|Collection $media + * @param string|string[] $tags + * @return void + */ + public function syncMedia($media, $tags): void; + + /** + * @param string|int|int[]|Media|Collection $media + * @param string|string[] $tags + * @return void + */ + public function detachMedia($media, $tags = null): void; + + /** + * @param string|string[] $tags + * @return void + */ + public function detachMediaTags($tags): void; + + /** + * @param string|string[] $tags + * @param bool $matchAll + * @return bool + */ + public function hasMedia($tags, bool $matchAll = false): bool; + + /** + * @param string|string[] $tags + * @param bool $matchAll + * @return Collection + */ + public function getMedia($tags, bool $matchAll = false): Collection; + + public function getMediaMatchAll(array $tags): Collection; + + /** + * @param string|string[] $tags + * @param bool $matchAll + * @return Media|null + */ + public function firstMedia($tags, bool $matchAll = false): ?Media; + + /** + * @param string|string[] $tags + * @param bool $matchAll + * @return Media|null + */ + public function lastMedia($tags, bool $matchAll = false): ?Media; + + public function getAllMediaByTag(): Collection; + + public function getTagsForMedia(Media $media): array; + + /** + * @param array|string $relations + * @return mixed + */ + public function load($relations); + + /** + * @param array $models + * @return MediableCollection + */ + public function newCollection(array $models = []); +} diff --git a/src/MediableServiceProvider.php b/src/MediableServiceProvider.php index 8d344f43..72948d3f 100644 --- a/src/MediableServiceProvider.php +++ b/src/MediableServiceProvider.php @@ -6,6 +6,7 @@ use CreateMediableTables; use Illuminate\Contracts\Container\Container; use Illuminate\Support\ServiceProvider; +use Mimey\MimeTypes; use Plank\Mediable\Commands\ImportMediaCommand; use Plank\Mediable\Commands\PruneMediaCommand; use Plank\Mediable\Commands\SyncMediaCommand; @@ -108,6 +109,7 @@ public function registerUploader(): void return new MediaUploader( $app['filesystem'], $app['mediable.source.factory'], + $app[ImageManipulator::class], $app['config']->get('mediable') ); }); diff --git a/src/SourceAdapters/DataUrlAdapter.php b/src/SourceAdapters/DataUrlAdapter.php new file mode 100644 index 00000000..de034dae --- /dev/null +++ b/src/SourceAdapters/DataUrlAdapter.php @@ -0,0 +1,22 @@ +source = $source; - } - - /** - * {@inheritdoc} - */ - public function getSource() - { - return $this->source; - } - - /** - * {@inheritdoc} - */ - public function path(): string - { - return $this->source->getPath() . '/' . $this->source->getFilename(); - } - - /** - * {@inheritdoc} - */ - public function filename(): string - { - return pathinfo($this->source->getFilename(), PATHINFO_FILENAME); - } - - /** - * {@inheritdoc} - */ - public function extension(): string - { - $extension = pathinfo($this->path(), PATHINFO_EXTENSION); - - if ($extension) { - return $extension; + $this->file = $source; + $path = $source->getRealPath(); + if ($path === false) { + throw ConfigurationException::invalidSource( + "File not found {$source->getPathname()}" + ); } - - return (string)FileHelper::guessExtension($this->mimeType()); - } - - /** - * {@inheritdoc} - */ - public function mimeType(): string - { - return (string)$this->source->getMimeType(); + parent::__construct( + Utils::streamFor( + Utils::tryFopen($path, 'rb') + ) + ); } /** * {@inheritdoc} */ - public function contents(): string + public function filename(): ?string { - return (string)file_get_contents($this->path()); - } - - /** - * @inheritdoc - */ - public function getStreamResource() - { - return fopen($this->path(), 'rb'); + return pathinfo($this->file->getRealPath(), PATHINFO_FILENAME) ?: null; } /** * {@inheritdoc} */ - public function valid(): bool + public function extension(): ?string { - return file_exists($this->path()); + return pathinfo($this->file->getRealPath(), PATHINFO_EXTENSION) ?: null; } - /** - * {@inheritdoc} - */ - public function size(): int + public function clientMimeType(): ?string { - return (int)filesize($this->path()); + return null; } } diff --git a/src/SourceAdapters/LocalPathAdapter.php b/src/SourceAdapters/LocalPathAdapter.php index f4a2772e..7dfe818c 100644 --- a/src/SourceAdapters/LocalPathAdapter.php +++ b/src/SourceAdapters/LocalPathAdapter.php @@ -3,63 +3,56 @@ namespace Plank\Mediable\SourceAdapters; +use GuzzleHttp\Psr7\Utils; +use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; +use Plank\Mediable\Exceptions\MediaUpload\FileNotFoundException; use Plank\Mediable\Helpers\File; +use Psr\Http\Message\StreamInterface; /** * Local Path Adapter. * * Adapts a string representing an absolute path */ -class LocalPathAdapter implements SourceAdapterInterface +class LocalPathAdapter extends StreamAdapter { - /** - * The source string. - * @var string - */ - protected $source; + protected string $filePath; - /** - * Constructor. - * @param string $source - */ public function __construct(string $source) { - $this->source = $source; - } - - public function getSource() - { - return $this->source; + $this->filePath = $source; + if (!is_file($source) || !is_readable($source)) { + throw ConfigurationException::invalidSource( + "File not found {$source}" + ); + } + parent::__construct( + Utils::streamFor(Utils::tryFopen($source, 'rb')) + ); } /** * {@inheritdoc} */ - public function path(): string + public function path(): ?string { - return $this->source; + return $this->filePath; } /** * {@inheritdoc} */ - public function filename(): string + public function filename(): ?string { - return pathinfo($this->source, PATHINFO_FILENAME); + return pathinfo($this->filePath, PATHINFO_FILENAME) ?: null; } /** * {@inheritdoc} */ - public function extension(): string + public function extension(): ?string { - $extension = pathinfo($this->source, PATHINFO_EXTENSION); - - if ($extension) { - return $extension; - } - - return (string)File::guessExtension($this->mimeType()); + return pathinfo($this->filePath, PATHINFO_EXTENSION) ?: null; } /** @@ -67,38 +60,11 @@ public function extension(): string */ public function mimeType(): string { - return mime_content_type($this->source); + return mime_content_type($this->filePath); } - /** - * {@inheritdoc} - */ - public function contents(): string - { - return (string)file_get_contents($this->source); - } - - /** - * @inheritdoc - */ - public function getStreamResource() - { - return fopen($this->path(), 'rb'); - } - - /** - * {@inheritdoc} - */ - public function valid(): bool - { - return is_readable($this->source); - } - - /** - * {@inheritdoc} - */ - public function size(): int + public function clientMimeType(): ?string { - return (int)filesize($this->source); + return null; } } diff --git a/src/SourceAdapters/RawContentAdapter.php b/src/SourceAdapters/RawContentAdapter.php index 6911bcc5..6f2d649f 100644 --- a/src/SourceAdapters/RawContentAdapter.php +++ b/src/SourceAdapters/RawContentAdapter.php @@ -3,7 +3,8 @@ namespace Plank\Mediable\SourceAdapters; -use Plank\Mediable\Helpers\File; +use GuzzleHttp\Psr7\Utils; +use Psr\Http\Message\StreamInterface; /** * Raw content Adapter. @@ -12,16 +13,8 @@ */ class RawContentAdapter implements SourceAdapterInterface { - /** - * The source object. - * @var string - */ - protected $source; + protected string $source; - /** - * Constructor. - * @param string $source - */ public function __construct(string $source) { $this->source = $source; @@ -30,33 +23,25 @@ public function __construct(string $source) /** * {@inheritdoc} */ - public function getSource() - { - return $this->source; - } - - /** - * {@inheritdoc} - */ - public function path(): string + public function path(): ?string { - return ''; + return null; } /** * {@inheritdoc} */ - public function filename(): string + public function filename(): ?string { - return ''; + return null; } /** * {@inheritdoc} */ - public function extension(): string + public function extension(): ?string { - return (string)File::guessExtension($this->mimeType()); + return null; } /** @@ -69,37 +54,31 @@ public function mimeType(): string return (string)$fileInfo->buffer($this->source); } - /** - * {@inheritdoc} - */ - public function contents(): string + public function clientMimeType(): ?string { - return $this->source; + return null; } /** - * @inheritdoc + * {@inheritdoc} */ - public function getStreamResource() + public function getStream(): StreamInterface { - $stream = fopen('php://memory', 'r+b'); - fwrite($stream, $this->contents()); - return $stream; + return Utils::streamFor($this->source); } /** * {@inheritdoc} */ - public function valid(): bool + public function size(): int { - return true; + return mb_strlen($this->source, '8bit') ?: 0; } - /** - * {@inheritdoc} - */ - public function size(): int + public function hash(string $algo = 'md5'): string { - return (int)mb_strlen($this->source, '8bit'); + $hash = hash_init($algo); + hash_update($hash, $this->source); + return hash_final($hash); } } diff --git a/src/SourceAdapters/RemoteUrlAdapter.php b/src/SourceAdapters/RemoteUrlAdapter.php index 72548233..6e0bc788 100644 --- a/src/SourceAdapters/RemoteUrlAdapter.php +++ b/src/SourceAdapters/RemoteUrlAdapter.php @@ -3,146 +3,62 @@ namespace Plank\Mediable\SourceAdapters; -use Plank\Mediable\Helpers\File; +use GuzzleHttp\Psr7\Utils; +use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; /** * URL Adapter. * * Adapts a string representing a URL */ -class RemoteUrlAdapter implements SourceAdapterInterface +class RemoteUrlAdapter extends StreamAdapter { - /** - * Cache of headers loaded from the remote server. - * @var array - */ - private $headers; - - /** - * The source string. - * @var string - */ - protected $source; + protected string $url; - /** - * Constructor. - * @param string $source - */ public function __construct(string $source) { - $this->source = $source; - } - - public function getSource() - { - return $this->source; - } - - /** - * {@inheritdoc} - */ - public function path(): string - { - return $this->source; - } - - /** - * {@inheritdoc} - */ - public function filename(): string - { - return pathinfo(parse_url($this->source, PHP_URL_PATH), PATHINFO_FILENAME); - } - - /** - * {@inheritdoc} - */ - public function extension(): string - { - $extension = pathinfo(parse_url($this->source, PHP_URL_PATH), PATHINFO_EXTENSION); - - if ($extension) { - return $extension; + $this->url = $source; + try { + $resource = Utils::tryFopen($source, 'rb'); + $stream = Utils::streamFor($resource); + } catch (\RuntimeException $e) { + throw ConfigurationException::invalidSource( + "Failed to connect to URL: {$e->getMessage()}", + $e + ); } - - return (string)File::guessExtension($this->mimeType()); - } - - /** - * {@inheritdoc} - */ - public function mimeType(): string - { - return $this->getHeader('Content-Type'); + parent::__construct( + $stream + ); } /** * {@inheritdoc} */ - public function contents(): string + public function path(): ?string { - return (string)file_get_contents($this->source); - } - - /** - * @inheritdoc - */ - public function getStreamResource() - { - return fopen($this->source, 'rb'); + return $this->url; } /** * {@inheritdoc} */ - public function valid(): bool + public function filename(): ?string { - return strpos((string)$this->getHeader(0), '200') !== false; + return pathinfo( + parse_url($this->url, PHP_URL_PATH), + PATHINFO_FILENAME + ) ?: null; } /** * {@inheritdoc} */ - public function size(): int - { - return (int)$this->getHeader('Content-Length'); - } - - /** - * Read a header value by name from the remote content. - * - * @param string|int $key Header name - * @return string|null - */ - private function getHeader($key, $default = null): ?string - { - if (!$this->headers) { - $this->headers = $this->getHeaders(); - } - if (array_key_exists($key, $this->headers)) { - //if redirects encountered, return the final values - if (is_array($this->headers[$key])) { - return end($this->headers[$key]); - } else { - return $this->headers[$key]; - } - } - - return null; - } - - /** - * Read all the headers from the remote content. - * - * @return array - */ - public function getHeaders(): array + public function extension(): ?string { - $headers = @get_headers( - $this->source, - version_compare(phpversion(), '8.0.0', '>=') ? true : 1 - ); - - return $headers ?: []; + return pathinfo( + parse_url($this->url, PHP_URL_PATH), + PATHINFO_EXTENSION + ) ?: null; } } diff --git a/src/SourceAdapters/SourceAdapterFactory.php b/src/SourceAdapters/SourceAdapterFactory.php index 062b2e40..5de485b4 100644 --- a/src/SourceAdapters/SourceAdapterFactory.php +++ b/src/SourceAdapters/SourceAdapterFactory.php @@ -14,15 +14,15 @@ class SourceAdapterFactory { /** * Map of which adapters to use for a given source class. - * @var string[] + * @var class-string[] */ - private $classAdapters = []; + private array $classAdapters = []; /** * Map of which adapters to use for a given string pattern. - * @var string[] + * @var class-string[] */ - private $patternAdapters = []; + private array $patternAdapters = []; /** * Create a Source Adapter for the provided source. @@ -53,7 +53,7 @@ public function create($source): SourceAdapterInterface /** * Specify the FQCN of a SourceAdapter class to use when the source inherits from a given class. - * @param string $adapterClass + * @param class-string $adapterClass * @param string $sourceClass * @return void * @@ -67,7 +67,7 @@ public function setAdapterForClass(string $adapterClass, string $sourceClass): v /** * Specify the FQCN of a SourceAdapter class to use when the source is a string matching the given pattern. - * @param string $adapterClass + * @param class-string $adapterClass * @param string $sourcePattern * @return void * @@ -82,7 +82,7 @@ public function setAdapterForPattern(string $adapterClass, string $sourcePattern /** * Choose an adapter class for the class of the provided object. * @param object $source - * @return string|null + * @return class-string|null */ private function adaptClass(object $source): ?string { @@ -98,7 +98,7 @@ private function adaptClass(object $source): ?string /** * Choose an adapter class for the provided string. * @param string $source - * @return string|null + * @return class-string|null */ private function adaptString(string $source): ?string { @@ -114,15 +114,13 @@ private function adaptString(string $source): ?string /** * Verify that the provided class implements the SourceAdapter interface. - * @param string $class + * @param class-string $class * @throws ConfigurationException If class is not valid * @return void */ private function validateAdapterClass(string $class): void { - $implements = class_implements($class, true); - - if (!in_array(SourceAdapterInterface::class, $implements)) { + if (!is_a($class, SourceAdapterInterface::class, true)) { throw ConfigurationException::cannotSetAdapter($class); } } diff --git a/src/SourceAdapters/SourceAdapterInterface.php b/src/SourceAdapters/SourceAdapterInterface.php index 8e7d35b2..c75ff461 100644 --- a/src/SourceAdapters/SourceAdapterInterface.php +++ b/src/SourceAdapters/SourceAdapterInterface.php @@ -3,6 +3,8 @@ namespace Plank\Mediable\SourceAdapters; +use Psr\Http\Message\StreamInterface; + /** * Source Adapter Interface. * @@ -10,60 +12,44 @@ */ interface SourceAdapterInterface { - /** - * Get the underlying source. - * @return mixed - */ - public function getSource(); - - /** - * Get the absolute path to the file. - * @return string - */ - public function path(): string; - /** * Get the name of the file. - * @return string + * @return string|null Returns null if the file name cannot be determined. */ - public function filename(): string; + public function filename(): ?string; /** * Get the extension of the file. - * @return string + * @return string|null Returns null if the extension cannot be determined. */ - public function extension(): string; + public function extension(): ?string; /** - * Get the MIME type of the file. - * @return string + * Get the MIME type inferred from the contents of the file. */ public function mimeType(): string; /** - * Return a stream resource if the original source can be converted to a stream. - * - * Prevents needing to load the entire contents of the file into memory. - * - * @return resource + * Get the MIME type of the file as provided by the client. + * This is not guaranteed to be accurate. + * @return string|null Returns null if no client MIME type is available. */ - public function getStreamResource(); + public function clientMimeType(): ?string; /** - * Get the body of the file. - * @return string + * Return a stream if the original source can be converted to a stream. + * Prevents needing to load the entire contents of the file into memory. */ - public function contents(): string; + public function getStream(): StreamInterface; /** - * Check if the file can be transferred. - * @return bool + * Determine the size of the file. */ - public function valid(): bool; + public function size(): int; /** - * Determine the size of the file. - * @return int + * Retrieve the md5 hash of the file. + * @param string $algo */ - public function size(): int; + public function hash(string $algo = 'md5'): string; } diff --git a/src/SourceAdapters/StreamAdapter.php b/src/SourceAdapters/StreamAdapter.php index 4052a991..23de5c63 100644 --- a/src/SourceAdapters/StreamAdapter.php +++ b/src/SourceAdapters/StreamAdapter.php @@ -3,7 +3,8 @@ namespace Plank\Mediable\SourceAdapters; -use Plank\Mediable\Helpers\File; +use GuzzleHttp\Psr7\CachingStream; +use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; use Psr\Http\Message\StreamInterface; /** @@ -13,19 +14,30 @@ */ class StreamAdapter implements SourceAdapterInterface { - const BUFFER_SIZE = 1024; + const BUFFER_SIZE = 2048; - /** - * The source object. - * @var StreamInterface - */ - protected $source; + private const TYPE_MEMORY = 'php'; + private const TYPE_DATA_URL = 'rfc2397'; + private const TYPE_HTTP = 'http'; + private const TYPE_FILE = 'plainfile'; + private const TYPE_FTP = 'ftp'; + + protected StreamInterface $source; + + protected StreamInterface $originalSource; /** * The contents of the stream. - * @var string|null + * @var string */ - protected $contents; + protected string $contents; + + protected int $size; + + /** @var array */ + protected array $hash; + + protected string $mimeType; /** * Constructor. @@ -33,48 +45,69 @@ class StreamAdapter implements SourceAdapterInterface */ public function __construct(StreamInterface $source) { - $this->source = $source; - } + if (!$source->isReadable()) { + throw ConfigurationException::invalidSource('Stream must be readable'); + } - /** - * {@inheritdoc} - */ - public function getSource() - { - return $this->source; + $this->source = $this->originalSource = $source; + if (!$this->source->isSeekable()) { + $this->source = new CachingStream($this->source); + } + + if ($this->getStreamType() === self::TYPE_HTTP) { + $code = $this->getHttpResponseCode(); + if (!$code || $code < 200 || $code >= 300) { + throw ConfigurationException::unrecognizedSource( + "Failed to fetch URL, received HTTP status code $code" + ); + } + } } /** * {@inheritdoc} */ - public function path(): string + private function path(): ?string { - return (string)$this->source->getMetadata('uri'); + $type = $this->getStreamType(); + if (in_array($type, [self::TYPE_DATA_URL, self::TYPE_MEMORY])) { + return null; + } + + return $this->originalSource->getMetadata('uri'); } /** * {@inheritdoc} */ - public function filename(): string + public function filename(): ?string { - return pathinfo(parse_url($this->path(), PHP_URL_PATH) ?? '', PATHINFO_FILENAME); + $path = $this->path(); + if (!$path) { + return null; + } + return pathinfo( + parse_url($this->path(), PHP_URL_PATH) ?? '', + PATHINFO_FILENAME + ) ?: null; } /** * {@inheritdoc} */ - public function extension(): string + public function extension(): ?string { - $extension = pathinfo( - parse_url($this->path(), PHP_URL_PATH) ?? '', - PATHINFO_EXTENSION - ); - - if ($extension) { - return $extension; + if ($path = $this->path()) { + $extension = pathinfo( + parse_url($path, PHP_URL_PATH) ?? '', + PATHINFO_EXTENSION + ); + if ($extension) { + return $extension; + } } - return (string)File::guessExtension($this->mimeType()); + return null; } /** @@ -82,71 +115,126 @@ public function extension(): string */ public function mimeType(): string { - $fileInfo = new \finfo(FILEINFO_MIME_TYPE); + if (!isset($this->mimeType)) { + $this->scanFile(); + } - return (string)$fileInfo->buffer($this->contents()); + return $this->mimeType; } - /** - * {@inheritdoc} - */ - public function contents(): string + public function clientMimeType(): ?string { - if (is_null($this->contents)) { - if ($this->source->isSeekable()) { - $this->contents = (string)$this->source; - } else { - $this->contents = $this->source->getContents(); - } + // supported primarily by data URLs + if ($mime = $this->originalSource->getMetadata('mediatype')) { + return $mime; } - return $this->contents; + if ($contentType = $this->getHttpHeader('Content-Type')) { + $mime = explode(';', $contentType)[0]; + + return $mime; + } + + return null; + } + + public function getStream(): StreamInterface + { + return $this->source; } /** - * @inheritdoc + * {@inheritdoc} */ - public function getStreamResource() + public function size(): int { - if ($this->source->isSeekable()) { - $this->source->rewind(); - } - - $stream = fopen('php://temp', 'r+b'); + $size = $this->source->getSize(); - while (!$this->source->eof()) { - $writeResult = fwrite($stream, $this->source->read(self::BUFFER_SIZE)); - if ($writeResult === false) { - throw new \RuntimeException("Could not read Stream"); - } + if (!is_null($size)) { + return $size; } - if ($this->source->isSeekable()) { - $this->source->rewind(); + if (!isset($this->size)) { + $this->scanFile(); } - return $stream; + return $this->size; } /** * {@inheritdoc} + * @param string $algo */ - public function valid(): bool + public function hash(string $algo = 'md5'): string { - return $this->source->isReadable(); + if (!isset($this->hash[$algo])) { + $this->scanFile($algo); + } + return $this->hash[$algo]; } /** - * {@inheritdoc} + * @return array|mixed|null */ - public function size(): int + private function getStreamType(): mixed { - $size = $this->source->getSize(); + return strtolower($this->originalSource->getMetadata('wrapper_type')); + } - if (!is_null($size)) { - return $size; + private function getHttpHeader($headerName): ?string + { + if ($this->getStreamType() !== self::TYPE_HTTP) { + return null; } - return (int)mb_strlen($this->contents(), '8bit'); + $headers = $this->originalSource->getMetadata('wrapper_data'); + if ($headers) { + foreach ($headers as $header) { + if (stripos($header, "$headerName: ") === 0) { + return substr($header, strlen($headerName) + 2); + } + } + } + + return null; + } + + private function getHttpResponseCode(): ?int + { + if ($this->getStreamType() !== self::TYPE_HTTP) { + return null; + } + $headers = $this->originalSource->getMetadata('wrapper_data'); + if (!empty($headers) + && preg_match('/HTTP\/\d+\.\d+\s+(\d+)/i', $headers[0], $matches) + ) { + return (int)$matches[1]; + } + + return null; + } + + private function scanFile(string $hashAlgorithm = 'md5'): void + { + $this->size = 0; + $this->source->rewind(); + try { + $hash = hash_init($hashAlgorithm); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + while (!$this->source->eof()) { + $buffer = $this->source->read(self::BUFFER_SIZE); + if (!isset($this->mimeType)) { + $this->mimeType = finfo_buffer($finfo, $buffer); + } + hash_update($hash, $buffer); + $this->size += strlen($buffer); + } + $this->hash[$hashAlgorithm] = hash_final($hash); + $this->source->rewind(); + } finally { + if (!empty($finfo)) { + finfo_close($finfo); + } + } } } diff --git a/src/SourceAdapters/StreamResourceAdapter.php b/src/SourceAdapters/StreamResourceAdapter.php index 9babcd30..73237956 100644 --- a/src/SourceAdapters/StreamResourceAdapter.php +++ b/src/SourceAdapters/StreamResourceAdapter.php @@ -3,8 +3,8 @@ namespace Plank\Mediable\SourceAdapters; +use GuzzleHttp\Psr7\Utils; use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; -use Plank\Mediable\Stream; /** * Stream resource Adapter. @@ -14,8 +14,7 @@ class StreamResourceAdapter extends StreamAdapter { /** - * The resource. - * @var resource|null + * @var resource */ protected $resource; @@ -27,27 +26,11 @@ class StreamResourceAdapter extends StreamAdapter public function __construct($source) { if (!is_resource($source) || get_resource_type($source) !== 'stream') { - throw ConfigurationException::unrecognizedSource($source); + throw ConfigurationException::invalidSource("Invalid stream resource"); } - parent::__construct(new Stream($source)); + parent::__construct(Utils::streamFor($source)); $this->resource = $source; } - - /** - * {@inheritdoc} - */ - public function getSource() - { - return $this->resource; - } - - /** - * @inheritdoc - */ - public function getStreamResource() - { - return $this->resource; - } } diff --git a/src/SourceAdapters/UploadedFileAdapter.php b/src/SourceAdapters/UploadedFileAdapter.php index e68d2875..fd2e79a7 100644 --- a/src/SourceAdapters/UploadedFileAdapter.php +++ b/src/SourceAdapters/UploadedFileAdapter.php @@ -3,6 +3,9 @@ namespace Plank\Mediable\SourceAdapters; +use GuzzleHttp\Psr7\Utils; +use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; +use Psr\Http\Message\StreamInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; /** @@ -12,11 +15,7 @@ */ class UploadedFileAdapter implements SourceAdapterInterface { - /** - * The source object. - * @var UploadedFile - */ - protected $source; + protected UploadedFile $uploadedFile; /** * Constructor. @@ -24,42 +23,28 @@ class UploadedFileAdapter implements SourceAdapterInterface */ public function __construct(UploadedFile $source) { - $this->source = $source; - } - - public function getSource() - { - return $this->source; - } - - /** - * {@inheritdoc} - */ - public function path(): string - { - return $this->source->getPath() . '/' . $this->source->getFilename(); + if (!$source->isValid()) { + throw ConfigurationException::invalidSource( + "Uploaded file is not valid: {$source->getErrorMessage()}" + ); + } + $this->uploadedFile = $source; } /** * {@inheritdoc} */ - public function filename(): string + public function filename(): ?string { - return pathinfo((string)$this->source->getClientOriginalName(), PATHINFO_FILENAME); + return pathinfo($this->uploadedFile->getClientOriginalName(), PATHINFO_FILENAME) ?: null; } /** * {@inheritdoc} */ - public function extension(): string + public function extension(): ?string { - $extension = $this->source->getClientOriginalExtension(); - - if ($extension) { - return $extension; - } - - return (string)$this->source->guessExtension(); + return $this->uploadedFile->getClientOriginalExtension() ?: null; } /** @@ -67,38 +52,36 @@ public function extension(): string */ public function mimeType(): string { - return (string)$this->source->getClientMimeType(); + return $this->uploadedFile->getMimeType(); } - /** - * {@inheritdoc} - */ - public function contents(): string + public function clientMimeType(): ?string { - return (string)file_get_contents($this->path()); + return $this->uploadedFile->getClientMimeType(); } /** - * @inheritdoc + * {@inheritdoc} */ - public function getStreamResource() + public function getStream(): StreamInterface { - return fopen($this->path(), 'rb'); + return Utils::streamFor(fopen($this->uploadedFile->getRealPath(), 'rb')); } /** * {@inheritdoc} */ - public function valid(): bool + public function size(): int { - return $this->source->isValid(); + return $this->uploadedFile->getSize() ?: 0; } /** * {@inheritdoc} + * @param string $algo */ - public function size(): int + public function hash(string $algo = 'md5'): string { - return (int)$this->source->getSize(); + return hash_file($algo, $this->uploadedFile->getRealPath()); } } diff --git a/src/Stream.php b/src/Stream.php deleted file mode 100644 index 910d03dd..00000000 --- a/src/Stream.php +++ /dev/null @@ -1,314 +0,0 @@ - [ - 'r' => true, - 'w+' => true, - 'r+' => true, - 'x+' => true, - 'c+' => true, - 'rb' => true, - 'w+b' => true, - 'r+b' => true, - 'x+b' => true, - 'c+b' => true, - 'rt' => true, - 'w+t' => true, - 'r+t' => true, - 'x+t' => true, - 'c+t' => true, - 'a+' => true - ], - 'write' => [ - 'w' => true, - 'w+' => true, - 'rw' => true, - 'r+' => true, - 'x+' => true, - 'c+' => true, - 'wb' => true, - 'w+b' => true, - 'r+b' => true, - 'x+b' => true, - 'c+b' => true, - 'w+t' => true, - 'r+t' => true, - 'x+t' => true, - 'c+t' => true, - 'a' => true, - 'a+' => true - ] - ]; - - /** - * @param resource $resource Stream resource to wrap. - * - * @throws \InvalidArgumentException if the stream is not a stream resource - */ - public function __construct($resource) - { - if (!is_resource($resource)) { - throw new \InvalidArgumentException('Stream must be a resource'); - } - - $this->resource = $resource; - $metadata = $this->getMetadata(); - $this->seekable = $metadata['seekable']; - $this->readable = isset(self::$readWriteHash['read'][$metadata['mode']]); - $this->writable = isset(self::$readWriteHash['write'][$metadata['mode']]); - $this->uri = $this->getMetadata('uri'); - } - - /** - * Closes the stream when the destructed - */ - public function __destruct() - { - $this->close(); - } - - /** - * {@inheritdoc} - */ - public function __toString(): string - { - try { - $this->seek(0); - return (string)stream_get_contents($this->resource); - } catch (\Exception $e) { - return ''; - } - } - - /** - * {@inheritdoc} - */ - public function getContents(): string - { - $contents = stream_get_contents($this->resource); - - if ($contents === false) { - throw new \RuntimeException('Unable to read stream contents'); - } - - return $contents; - } - - /** - * {@inheritdoc} - */ - public function close(): void - { - if (!$this->resource) { - return; - } - - if (is_resource($this->resource)) { - fclose($this->resource); - } - } - - /** - * {@inheritdoc} - */ - public function detach() - { - if (!$this->resource) { - return null; - } - - $resource = $this->resource; - unset($this->resource); - $this->size = $this->uri = null; - $this->readable = $this->writable = $this->seekable = false; - - return $resource; - } - - /** - * {@inheritdoc} - */ - public function getSize(): ?int - { - if ($this->size !== null) { - return $this->size; - } - - if (!$this->resource) { - return null; - } - - // Clear the stat cache if the stream has a URI - if ($this->uri) { - clearstatcache(true, $this->uri); - } - - $stats = fstat($this->resource); - - if (isset($stats['size'])) { - $this->size = $stats['size']; - return $this->size; - } - - return null; - } - - /** - * {@inheritdoc} - */ - public function isReadable(): bool - { - return $this->readable; - } - - /** - * {@inheritdoc} - */ - public function isWritable(): bool - { - return $this->writable; - } - - /** - * {@inheritdoc} - */ - public function isSeekable(): bool - { - return $this->seekable; - } - - /** - * {@inheritdoc} - */ - public function eof(): bool - { - return !$this->resource || feof($this->resource); - } - - /** - * {@inheritdoc} - */ - public function tell(): int - { - if (!$this->resource) { - throw new \RuntimeException('No resource available; cannot tell position'); - } - - $result = ftell($this->resource); - - if ($result === false) { - throw new \RuntimeException('Unable to determine stream position'); - } - - return $result; - } - - /** - * {@inheritdoc} - */ - public function rewind(): void - { - $this->seek(0); - } - - /** - * {@inheritdoc} - */ - public function seek(int $offset, int $whence = SEEK_SET): void - { - if (!$this->resource) { - throw new \RuntimeException('No resource available; cannot seek position'); - } - - if (!$this->isSeekable()) { - throw new \RuntimeException('Stream is not seekable'); - } - - $result = fseek($this->resource, $offset, $whence); - - if ($result === -1) { - throw new \RuntimeException('Unable to seek to stream position ' - . $offset . ' with whence ' . var_export($whence, true)); - } - } - - /** - * {@inheritdoc} - */ - public function read(int $length): string - { - if (!$this->resource) { - throw new \RuntimeException('No resource available; cannot read'); - } - - if (!$this->isReadable()) { - throw new \RuntimeException('Cannot read from non-readable stream'); - } - - $result = fread($this->resource, $length); - - if ($result === false) { - throw new \RuntimeException('Unable to read from stream'); - } - - return $result; - } - - /** - * {@inheritdoc} - */ - public function write(string $string): int - { - if (!$this->resource) { - throw new \RuntimeException('No resource available; cannot write'); - } - - if (!$this->isWritable()) { - throw new \RuntimeException('Cannot write to a non-writable stream'); - } - - // We can't know the size after writing anything - $this->size = null; - $result = fwrite($this->resource, $string); - - if ($result === false) { - throw new \RuntimeException('Unable to write to stream'); - } - - return $result; - } - - /** - * {@inheritdoc} - */ - public function getMetadata(?string $key = null) - { - $metadata = stream_get_meta_data($this->resource); - - if (is_null($key)) { - return $metadata; - } - - return isset($metadata[$key]) ? $metadata[$key] : null; - } -} diff --git a/src/UrlGenerators/BaseUrlGenerator.php b/src/UrlGenerators/BaseUrlGenerator.php index 52afe6b5..34abaa6e 100644 --- a/src/UrlGenerators/BaseUrlGenerator.php +++ b/src/UrlGenerators/BaseUrlGenerator.php @@ -8,17 +8,12 @@ abstract class BaseUrlGenerator implements UrlGeneratorInterface { - /** - * Configuration Repository. - * @var \Illuminate\Contracts\Config\Repository - */ - protected $config; + protected Config $config; /** * Media instance being linked. - * @var \Plank\Mediable\Media */ - protected $media; + protected ?Media $media = null; /** * Constructor. @@ -52,7 +47,7 @@ public function isPubliclyAccessible(): bool * @param mixed $default * @return mixed */ - protected function getDiskConfig(string $key, $default = null) + protected function getDiskConfig(string $key, $default = null): mixed { return $this->config->get("filesystems.disks.{$this->media->disk}.{$key}", $default); } diff --git a/src/UrlGenerators/LocalUrlGenerator.php b/src/UrlGenerators/LocalUrlGenerator.php index a4258974..529f27bb 100644 --- a/src/UrlGenerators/LocalUrlGenerator.php +++ b/src/UrlGenerators/LocalUrlGenerator.php @@ -9,10 +9,7 @@ class LocalUrlGenerator extends BaseUrlGenerator { - /** - * @var FilesystemManager - */ - protected $filesystem; + protected FilesystemManager $filesystem; /** * Constructor. diff --git a/src/UrlGenerators/S3UrlGenerator.php b/src/UrlGenerators/S3UrlGenerator.php index fc9fa600..f80bf277 100644 --- a/src/UrlGenerators/S3UrlGenerator.php +++ b/src/UrlGenerators/S3UrlGenerator.php @@ -13,11 +13,7 @@ class S3UrlGenerator extends BaseUrlGenerator implements TemporaryUrlGeneratorInterface { - /** - * Filesystem Manager. - * @var FilesystemManager - */ - protected $filesystem; + protected FilesystemManager $filesystem; /** * Constructor. @@ -51,26 +47,6 @@ public function getUrl(): string public function getTemporaryUrl(\DateTimeInterface $expiry): string { $filesystem = $this->filesystem->disk($this->media->disk); - - // Laravel 9+ / Flysystem 2+ - if (method_exists($filesystem, 'temporaryUrl')) { - return $filesystem->temporaryUrl($this->media->getDiskPath(), $expiry); - } - - // Earlier versions - $root = config("filesystems.disks.{$this->media->disk}.root", ''); - $adapter = $filesystem->getDriver()->getAdapter(); - $command = $adapter->getClient()->getCommand( - 'GetObject', - [ - 'Bucket' => $adapter->getBucket(), - 'Key' => File::joinPathComponents($root, $this->media->getDiskPath()), - ] - ); - - return (string)$adapter->getClient()->createPresignedRequest( - $command, - $expiry - )->getUri(); + return $filesystem->temporaryUrl($this->media->getDiskPath(), $expiry); } } diff --git a/src/UrlGenerators/UrlGeneratorFactory.php b/src/UrlGenerators/UrlGeneratorFactory.php index a5b8b647..2d054680 100644 --- a/src/UrlGenerators/UrlGeneratorFactory.php +++ b/src/UrlGenerators/UrlGeneratorFactory.php @@ -12,7 +12,7 @@ class UrlGeneratorFactory * map of UrlGenerator classes to use for different filesystem drivers. * @var string[] */ - protected $driver_generators = []; + protected array $driver_generators = []; /** * Get a UrlGenerator instance for a media. diff --git a/tests/Factories/ModelFactory.php b/tests/Factories/ModelFactory.php index 1866a53e..286adbb0 100644 --- a/tests/Factories/ModelFactory.php +++ b/tests/Factories/ModelFactory.php @@ -4,18 +4,20 @@ use Plank\Mediable\Tests\Mocks\SampleMediable; use Plank\Mediable\Tests\Mocks\SampleMediableSoftDelete; +$factory = app(Illuminate\Database\Eloquent\Factory::class); $factory->define(Plank\Mediable\Media::class, function (Faker\Generator $faker) { $types = config('mediable.aggregate_types'); $type = $faker->randomElement(array_keys($types)); return [ 'disk' => 'tmp', - 'directory' => implode('/', $faker->words($faker->randomDigit)), + 'directory' => implode('/', $faker->words($faker->randomDigit())), 'filename' => $faker->word, 'extension' => $faker->randomElement($types[$type]['extensions']), 'mime_type' => $faker->randomElement($types[$type]['mime_types']), 'aggregate_type' => $type, 'size' => $faker->randomNumber(), + 'alt' => $faker->sentence, ]; }); @@ -25,7 +27,7 @@ return [ 'disk' => 'tmp', - 'directory' => implode('/', $faker->words($faker->randomDigit)), + 'directory' => implode('/', $faker->words($faker->randomDigit())), 'filename' => $faker->word, 'extension' => $faker->randomElement($types[$type]['extensions']), 'mime_type' => $faker->randomElement($types[$type]['mime_types']), diff --git a/tests/Integration/Commands/ImportMediaCommandTest.php b/tests/Integration/Commands/ImportMediaCommandTest.php index 1d331b05..118918d8 100644 --- a/tests/Integration/Commands/ImportMediaCommandTest.php +++ b/tests/Integration/Commands/ImportMediaCommandTest.php @@ -25,7 +25,7 @@ public function getEnvironmentSetUp($app) $app['config']->set('mediable.strict_type_checking', false); } - public function test_it_creates_media_for_unmatched_files() + public function test_it_creates_media_for_unmatched_files(): void { $artisan = $this->getArtisan(); $media1 = factory(Media::class)->make(['disk' => 'tmp', 'filename' => 'foo']); @@ -38,11 +38,11 @@ public function test_it_creates_media_for_unmatched_files() $this->assertEquals("Imported 1 file(s).\n", $artisan->output()); $this->assertEquals( ['bar', 'foo'], - Media::orderBy('filename')->pluck('filename')->toArray() + Media::query()->orderBy('filename')->pluck('filename')->toArray() ); } - public function test_it_creates_media_for_unmatched_files_in_directory() + public function test_it_creates_media_for_unmatched_files_in_directory(): void { $artisan = $this->getArtisan(); $media1 = factory(Media::class)->make( @@ -57,10 +57,10 @@ public function test_it_creates_media_for_unmatched_files_in_directory() $artisan->call('media:import', ['disk' => 'tmp', '--directory' => 'a/b']); $this->assertEquals("Imported 1 file(s).\n", $artisan->output()); - $this->assertEquals(['bar'], Media::pluck('filename')->toArray()); + $this->assertEquals(['bar'], Media::query()->pluck('filename')->toArray()); } - public function test_it_creates_media_for_unmatched_files_non_recursively() + public function test_it_creates_media_for_unmatched_files_non_recursively(): void { $artisan = $this->getArtisan(); $media1 = factory(Media::class)->make( @@ -78,10 +78,10 @@ public function test_it_creates_media_for_unmatched_files_non_recursively() ); $this->assertEquals("Imported 1 file(s).\n", $artisan->output()); - $this->assertEquals(['foo'], Media::pluck('filename')->toArray()); + $this->assertEquals(['foo'], Media::query()->pluck('filename')->toArray()); } - public function test_it_skips_files_of_unmatched_aggregate_type() + public function test_it_skips_files_of_unmatched_aggregate_type(): void { $artisan = $this->getArtisan(); $filesystem = app(FilesystemManager::class); @@ -105,7 +105,7 @@ public function test_it_skips_files_of_unmatched_aggregate_type() ); } - public function test_it_updates_existing_media() + public function test_it_updates_existing_media(): void { $artisan = $this->getArtisan(); $media1 = factory(Media::class)->create( @@ -133,7 +133,7 @@ public function test_it_updates_existing_media() $artisan->call('media:import', ['disk' => 'tmp', '--force' => true]); $this->assertEquals( ['image', 'image'], - Media::pluck('aggregate_type')->toArray() + Media::query()->pluck('aggregate_type')->toArray() ); $this->assertEquals( "Imported 0 file(s).\nUpdated 1 record(s).\nSkipped 1 file(s).\n", @@ -141,7 +141,7 @@ public function test_it_updates_existing_media() ); } - protected function getArtisan() + protected function getArtisan(): Artisan { return app(Artisan::class); } diff --git a/tests/Integration/Commands/PruneMediaCommandTest.php b/tests/Integration/Commands/PruneMediaCommandTest.php index 511e345f..fb3ec249 100644 --- a/tests/Integration/Commands/PruneMediaCommandTest.php +++ b/tests/Integration/Commands/PruneMediaCommandTest.php @@ -16,7 +16,7 @@ public function setUp(): void $this->withoutMockingConsoleOutput(); } - public function test_it_deletes_media_without_files() + public function test_it_deletes_media_without_files(): void { $artisan = $this->getArtisan(); $media1 = factory(Media::class)->create(['id' => 1, 'disk' => 'tmp']); @@ -25,11 +25,11 @@ public function test_it_deletes_media_without_files() $artisan->call('media:prune', ['disk' => 'tmp']); - $this->assertEquals([2], Media::pluck('id')->toArray()); + $this->assertEquals([2], Media::query()->pluck('id')->toArray()); $this->assertEquals("Pruned 1 record(s).\n", $artisan->output()); } - public function test_it_prunes_directory() + public function test_it_prunes_directory(): void { $artisan = $this->getArtisan(); $media1 = factory(Media::class)->create( @@ -41,11 +41,11 @@ public function test_it_prunes_directory() $artisan->call('media:prune', ['disk' => 'tmp', '--directory' => 'foo']); - $this->assertEquals([1], Media::pluck('id')->toArray()); + $this->assertEquals([1], Media::query()->pluck('id')->toArray()); $this->assertEquals("Pruned 1 record(s).\n", $artisan->output()); } - public function test_it_prunes_non_recursively() + public function test_it_prunes_non_recursively(): void { $artisan = $this->getArtisan(); $media1 = factory(Media::class)->create( @@ -57,11 +57,11 @@ public function test_it_prunes_non_recursively() $artisan->call('media:prune', ['disk' => 'tmp', '--non-recursive' => true]); - $this->assertEquals([2], Media::pluck('id')->toArray()); + $this->assertEquals([2], Media::query()->pluck('id')->toArray()); $this->assertEquals("Pruned 1 record(s).\n", $artisan->output()); } - public function getArtisan() + public function getArtisan(): Artisan { return app(Artisan::class); } diff --git a/tests/Integration/Commands/SyncMediaCommandTest.php b/tests/Integration/Commands/SyncMediaCommandTest.php index e6b8c048..b814eb0c 100644 --- a/tests/Integration/Commands/SyncMediaCommandTest.php +++ b/tests/Integration/Commands/SyncMediaCommandTest.php @@ -2,21 +2,22 @@ namespace Plank\Mediable\Tests\Integration\Commands; +use PHPUnit\Framework\MockObject\MockObject; use Plank\Mediable\Commands\SyncMediaCommand; use Plank\Mediable\Tests\TestCase; class SyncMediaCommandTest extends TestCase { - public function test_it_calls_prune_and_install() + public function test_it_calls_prune_and_install(): void { $this->withoutMockingConsoleOutput(); - /** @var SyncMediaCommand $command */ + /** @var SyncMediaCommand|MockObject $command */ $command = $this->getMockBuilder(SyncMediaCommand::class) - ->setMethods(['call', 'option', 'argument']) + ->onlyMethods(['call', 'option', 'argument']) ->getMock(); $command->expects($this->exactly(2)) ->method('call') - ->withConsecutive( + ->with(...$this->withConsecutive( [ $this->equalTo('media:prune'), [ @@ -34,7 +35,7 @@ public function test_it_calls_prune_and_install() '--force' => false ] ] - ); + )); $command->handle(); } diff --git a/tests/Integration/ConnectionTest.php b/tests/Integration/ConnectionTest.php index 4baf06b9..31183cff 100644 --- a/tests/Integration/ConnectionTest.php +++ b/tests/Integration/ConnectionTest.php @@ -16,7 +16,7 @@ public function setUp(): void $this->useDatabase(); } - public function test_it_can_use_different_connection() + public function test_it_can_use_different_connection(): void { $media = factory(Media::class)->create(['id' => 1]); $mediable = factory(SampleMediable::class)->create(); @@ -32,7 +32,7 @@ public function test_it_can_use_different_connection() $this->assertEquals(1, $mediable->firstMedia('foo')->id); } - protected function setupConnection() + protected function setupConnection(): void { $this->app['config']->set('database.connections.my_connection', [ 'driver' => 'sqlite', diff --git a/tests/Integration/HandlesMediaExceptionsTest.php b/tests/Integration/HandlesMediaExceptionsTest.php index 26528488..fed16c79 100644 --- a/tests/Integration/HandlesMediaExceptionsTest.php +++ b/tests/Integration/HandlesMediaExceptionsTest.php @@ -15,7 +15,7 @@ class HandlesMediaExceptionsTest extends TestCase { - public function test_it_returns_a_403_for_disallowed_disk() + public function test_it_returns_a_403_for_disallowed_disk(): void { $e = (new SampleExceptionHandler())->render( ForbiddenException::diskNotAllowed('foo') @@ -24,7 +24,7 @@ public function test_it_returns_a_403_for_disallowed_disk() $this->assertHttpException($e, 403); } - public function test_it_returns_a_404_for_missing_file() + public function test_it_returns_a_404_for_missing_file(): void { $e = (new SampleExceptionHandler())->render( FileNotFoundException::fileNotFound('non/existing.jpg') @@ -33,7 +33,7 @@ public function test_it_returns_a_404_for_missing_file() $this->assertHttpException($e, 404); } - public function test_it_returns_a_409_on_duplicate_file() + public function test_it_returns_a_409_on_duplicate_file(): void { $e = (new SampleExceptionHandler())->render( FileExistsException::fileExists('already/existing.jpg') @@ -42,7 +42,7 @@ public function test_it_returns_a_409_on_duplicate_file() $this->assertHttpException($e, 409); } - public function test_it_returns_a_413_for_too_big_file() + public function test_it_returns_a_413_for_too_big_file(): void { $e = (new SampleExceptionHandler())->render( FileSizeException::fileIsTooBig(3, 2) @@ -51,7 +51,7 @@ public function test_it_returns_a_413_for_too_big_file() $this->assertHttpException($e, 413); } - public function test_it_returns_a_415_for_type_mismatch() + public function test_it_returns_a_415_for_type_mismatch(): void { $e = (new SampleExceptionHandler())->render( FileNotSupportedException::strictTypeMismatch('text/foo', 'bar') @@ -60,7 +60,7 @@ public function test_it_returns_a_415_for_type_mismatch() $this->assertHttpException($e, 415); } - public function test_it_returns_a_415_for_unknown_type() + public function test_it_returns_a_415_for_unknown_type(): void { $e = (new SampleExceptionHandler())->render( FileNotSupportedException::unrecognizedFileType('text/foo', 'bar') @@ -69,7 +69,7 @@ public function test_it_returns_a_415_for_unknown_type() $this->assertHttpException($e, 415); } - public function test_it_returns_a_415_for_restricted_type() + public function test_it_returns_a_415_for_restricted_type(): void { $e = (new SampleExceptionHandler())->render( FileNotSupportedException::mimeRestricted('text/foo', ['text/bar']) @@ -78,7 +78,7 @@ public function test_it_returns_a_415_for_restricted_type() $this->assertHttpException($e, 415); } - public function test_it_returns_a_415_for_restricted_extension() + public function test_it_returns_a_415_for_restricted_extension(): void { $e = (new SampleExceptionHandler())->render( FileNotSupportedException::extensionRestricted('foo', ['bar']) @@ -87,7 +87,7 @@ public function test_it_returns_a_415_for_restricted_extension() $this->assertHttpException($e, 415); } - public function test_it_returns_a_415_for_restricted_aggregate_type() + public function test_it_returns_a_415_for_restricted_aggregate_type(): void { $e = (new SampleExceptionHandler())->render( FileNotSupportedException::aggregateTypeRestricted('foo', ['bar']) @@ -96,7 +96,7 @@ public function test_it_returns_a_415_for_restricted_aggregate_type() $this->assertHttpException($e, 415); } - public function test_it_returns_a_500_for_other_exception_types() + public function test_it_returns_a_500_for_other_exception_types(): void { $e = (new SampleExceptionHandler())->render( new ConfigurationException() @@ -105,7 +105,7 @@ public function test_it_returns_a_500_for_other_exception_types() $this->assertHttpException($e, 500); } - public function test_it_skips_any_other_exception() + public function test_it_skips_any_other_exception(): void { $e = (new SampleExceptionHandler())->render( new Exception() @@ -114,7 +114,7 @@ public function test_it_skips_any_other_exception() $this->assertFalse($e instanceof HttpException); } - protected function assertHttpException($e, $code) + protected function assertHttpException($e, $code): void { $this->assertInstanceOf(HttpException::class, $e); /** @var HttpException $e */ diff --git a/tests/Integration/Helpers/FileTest.php b/tests/Integration/Helpers/FileTest.php index d6e95427..917a8ca1 100644 --- a/tests/Integration/Helpers/FileTest.php +++ b/tests/Integration/Helpers/FileTest.php @@ -7,7 +7,7 @@ class FileTest extends TestCase { - public function test_it_provides_a_cleaned_dirname() + public function test_it_provides_a_cleaned_dirname(): void { $this->assertEquals('', File::cleanDirname('')); $this->assertEquals('', File::cleanDirname('/')); @@ -16,19 +16,19 @@ public function test_it_provides_a_cleaned_dirname() $this->assertEquals('foo/bar', File::cleanDirname('/foo/bar/baz.jpg')); } - public function test_it_converts_bytes_to_readable_strings() + public function test_it_converts_bytes_to_readable_strings(): void { $this->assertEquals('0 B', File::readableSize(0)); $this->assertEquals('1 KB', File::readableSize(1025, 0)); $this->assertEquals('1.1 MB', File::readableSize(1024 * 1024 + 1024 * 100, 2)); } - public function test_it_guesses_the_extension_given_a_mime_type() + public function test_it_guesses_the_extension_given_a_mime_type(): void { $this->assertEquals('png', File::guessExtension('image/png')); } - public function test_it_sanitizes_filenames() + public function test_it_sanitizes_filenames(): void { $this->assertEquals( 'hello-world-what-ss_new-with.you', @@ -36,7 +36,7 @@ public function test_it_sanitizes_filenames() ); } - public function test_it_sanitizes_filenames_with_locale() + public function test_it_sanitizes_filenames_with_locale(): void { $this->assertEquals( 'hello-world-what-sz_new-with.you', @@ -44,7 +44,7 @@ public function test_it_sanitizes_filenames_with_locale() ); } - public function test_it_sanitizes_paths() + public function test_it_sanitizes_paths(): void { $this->assertEquals( 'hello/world-what-s_new-with.you', @@ -52,7 +52,7 @@ public function test_it_sanitizes_paths() ); } - public function test_it_joins_path_components() + public function test_it_joins_path_components(): void { $this->assertEquals('', File::joinPathComponents('', '')); $this->assertEquals('foo', File::joinPathComponents('foo', '')); diff --git a/tests/Integration/ImageManipulationTest.php b/tests/Integration/ImageManipulationTest.php index 6999214d..c175750f 100644 --- a/tests/Integration/ImageManipulationTest.php +++ b/tests/Integration/ImageManipulationTest.php @@ -4,17 +4,19 @@ use Plank\Mediable\ImageManipulation; use Plank\Mediable\Tests\TestCase; +use Spatie\ImageOptimizer\Optimizers\Jpegoptim; +use Spatie\ImageOptimizer\Optimizers\Pngquant; class ImageManipulationTest extends TestCase { - public function test_can_get_set_manipulation_callback() + public function test_can_get_set_manipulation_callback(): void { $callback = $this->getMockCallable(); $manipulation = new ImageManipulation($callback); $this->assertSame($callback, $manipulation->getCallback()); } - public function test_can_get_set_output_quality() + public function test_can_get_set_output_quality(): void { $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertEquals(90, $manipulation->getOutputQuality()); @@ -26,7 +28,7 @@ public function test_can_get_set_output_quality() $this->assertEquals(50, $manipulation->getOutputQuality()); } - public function test_can_get_set_output_format() + public function test_can_get_set_output_format(): void { $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertNull($manipulation->getOutputFormat()); @@ -46,7 +48,7 @@ public function test_can_get_set_output_format() $this->assertEquals('jpg', $manipulation->getOutputFormat()); } - public function test_can_get_set_before_save_callback() + public function test_can_get_set_before_save_callback(): void { $callback = $this->getMockCallable(); $manipulation = new ImageManipulation($this->getMockCallable()); @@ -56,14 +58,14 @@ public function test_can_get_set_before_save_callback() $this->assertSame($callback, $manipulation->getBeforeSave()); } - public function test_destination_setters() + public function test_destination_setters(): void { $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertNull($manipulation->getDisk()); $this->assertNull($manipulation->getDirectory()); $this->assertNull($manipulation->getFilename()); - $this->assertFalse($manipulation->usingHashForFilename()); + $this->assertFalse($manipulation->isUsingHashForFilename()); $manipulation->toDisk('tmp'); $this->assertEquals('tmp', $manipulation->getDisk()); @@ -77,18 +79,24 @@ public function test_destination_setters() $manipulation->useFilename('potato'); $this->assertEquals('potato', $manipulation->getFilename()); - $this->assertFalse($manipulation->usingHashForFilename()); + $this->assertFalse($manipulation->isUsingHashForFilename()); $manipulation->useHashForFilename(); $this->assertNull($manipulation->getFilename()); - $this->assertTrue($manipulation->usingHashForFilename()); + $this->assertTrue($manipulation->isUsingHashForFilename()); + $this->assertEquals('md5', $manipulation->getHashFilenameAlgo()); + + $manipulation->useHashForFilename('sha1'); + $this->assertNull($manipulation->getFilename()); + $this->assertTrue($manipulation->isUsingHashForFilename()); + $this->assertEquals('sha1', $manipulation->getHashFilenameAlgo()); $manipulation->useOriginalFilename(); $this->assertNull($manipulation->getFilename()); - $this->assertFalse($manipulation->usingHashForFilename()); + $this->assertFalse($manipulation->isUsingHashForFilename()); } - public function test_get_duplicate_behaviours() + public function test_get_duplicate_behaviours(): void { $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertEquals( @@ -107,7 +115,7 @@ public function test_get_duplicate_behaviours() ); } - public function test_visibility() + public function test_visibility(): void { $manipulation = new ImageManipulation($this->getMockCallable()); $this->assertNull($manipulation->getVisibility()); @@ -133,4 +141,38 @@ public function test_visibility() $manipulation->setVisibility(null); $this->assertNull($manipulation->getVisibility()); } + + public function test_it_can_configure_image_optimization(): void + { + config(['mediable.image_optimization.enabled' => true]); + config(['mediable.image_optimization.optimizers' => [Pngquant::class => ['--arg']]]); + + $manipulation = new ImageManipulation($this->getMockCallable()); + $this->assertTrue($manipulation->shouldOptimize()); + $optimizerChain = $manipulation->getOptimizerChain(); + $optimizers = $optimizerChain->getOptimizers(); + $this->assertCount(1, $optimizers); + $this->assertInstanceOf(Pngquant::class, $optimizers[0]); + + $manipulation->noOptimization(); + $this->assertFalse($manipulation->shouldOptimize()); + + config(['mediable.image_optimization.enabled' => false]); + $manipulation = new ImageManipulation($this->getMockCallable()); + $this->assertFalse($manipulation->shouldOptimize()); + + $manipulation->optimize(); + $this->assertTrue($manipulation->shouldOptimize()); + $optimizerChain = $manipulation->getOptimizerChain(); + $optimizers = $optimizerChain->getOptimizers(); + $this->assertCount(1, $optimizers); + $this->assertInstanceOf(Pngquant::class, $optimizers[0]); + + $manipulation->optimize([Jpegoptim::class => ['--arg']]); + $this->assertTrue($manipulation->shouldOptimize()); + $optimizerChain = $manipulation->getOptimizerChain(); + $optimizers = $optimizerChain->getOptimizers(); + $this->assertCount(1, $optimizers); + $this->assertInstanceOf(Jpegoptim::class, $optimizers[0]); + } } diff --git a/tests/Integration/ImageManipulatorTest.php b/tests/Integration/ImageManipulatorTest.php index c44745a7..9b6aa24f 100644 --- a/tests/Integration/ImageManipulatorTest.php +++ b/tests/Integration/ImageManipulatorTest.php @@ -3,17 +3,21 @@ namespace Plank\Mediable\Tests\Integration; use Illuminate\Filesystem\FilesystemManager; +use Intervention\Image\Drivers\Gd\Driver as GdDriver; use Intervention\Image\Image; use Intervention\Image\ImageManager; use Plank\Mediable\Exceptions\ImageManipulationException; use Plank\Mediable\ImageManipulation; use Plank\Mediable\ImageManipulator; +use Plank\Mediable\ImageOptimizer; use Plank\Mediable\Media; use Plank\Mediable\Tests\TestCase; +use Spatie\ImageOptimizer\Optimizers\Optipng; +use Spatie\ImageOptimizer\Optimizers\Pngquant; class ImageManipulatorTest extends TestCase { - public function test_it_can_be_accessed_via_facade() + public function test_it_can_be_accessed_via_facade(): void { $this->assertInstanceOf( ImageManipulator::class, @@ -21,7 +25,7 @@ public function test_it_can_be_accessed_via_facade() ); } - public function test_it_sets_and_has_variants() + public function test_it_sets_and_has_variants(): void { $manipulator = $this->getManipulator(); $this->assertFalse($manipulator->hasVariantDefinition('foo')); @@ -32,7 +36,7 @@ public function test_it_sets_and_has_variants() $this->assertTrue($manipulator->hasVariantDefinition('foo')); } - public function test_it_retrieves_all_variants() + public function test_it_retrieves_all_variants(): void { $manipulator = $this->getManipulator(); $variant = new ImageManipulation($this->getMockCallable()); @@ -60,7 +64,7 @@ public function test_it_retrieves_all_variants() $this->assertEquals(['foo', 'bar'], $manipulator->getAllVariantNames()); } - public function test_it_can_store_and_retrieve_by_tag() + public function test_it_can_store_and_retrieve_by_tag(): void { $manipulator = $this->getManipulator(); $variant = new ImageManipulation($this->getMockCallable()); @@ -92,7 +96,7 @@ public function test_it_can_store_and_retrieve_by_tag() $this->assertEquals([], $manipulator->getVariantNamesByTag('c')); } - public function test_it_throws_for_non_image_media() + public function test_it_throws_for_non_image_media(): void { $this->expectException(ImageManipulationException::class); $this->expectExceptionMessage( @@ -104,7 +108,7 @@ public function test_it_throws_for_non_image_media() ); } - public function test_it_throws_for_unknown_variants() + public function test_it_throws_for_unknown_variants(): void { $this->expectException(ImageManipulationException::class); $this->expectExceptionMessage("Unknown variant 'invalid'."); @@ -114,7 +118,7 @@ public function test_it_throws_for_unknown_variants() ); } - public function test_it_throws_for_indeterminate_output_format() + public function test_it_throws_for_indeterminate_output_format(): void { $this->useFilesystem('tmp'); $this->expectException(ImageManipulationException::class); @@ -138,7 +142,7 @@ function (Image $image) { $manipulator->createImageVariant($media, 'foo'); } - public function test_it_can_create_a_variant() + public function test_it_can_create_a_variant(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -190,6 +194,7 @@ function (Image $image) { $result->size, [ 449, // Laravel <=8 + 442, // laravel 11 438 // Laravel 9+ ] ); @@ -198,7 +203,7 @@ function (Image $image) { $this->assertTrue($media->fileExists()); } - public function test_it_can_create_a_variant_of_a_variant() + public function test_it_can_create_a_variant_of_a_variant(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -236,7 +241,7 @@ function (Image $image) { $this->assertTrue($media->fileExists()); } - public function formatProvider() + public static function formatProvider(): array { return [ ['jpg', 'image/jpeg', 100], @@ -252,7 +257,7 @@ public function test_it_can_create_a_variant_of_a_different_format( string $format, string $mime, int $quality - ) { + ): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -284,7 +289,7 @@ function (Image $image) { $this->assertTrue($media->fileExists()); } - public function test_it_can_output_to_custom_destination() + public function test_it_can_output_to_custom_destination(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -318,7 +323,7 @@ function (Image $image) { $this->assertTrue($media->fileExists()); } - public function test_it_can_output_to_hash_filename() + public function test_it_can_output_to_hash_filename(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -342,7 +347,7 @@ function (Image $image) { } )->toDisk('uploads') ->toDirectory('potato') - ->useHashForFilename(); + ->useHashForFilename('sha1'); $imageManipulator = $this->getManipulator(); $imageManipulator->defineVariant('test', $manipulation); @@ -350,11 +355,11 @@ function (Image $image) { $this->assertEquals('uploads', $result->disk); $this->assertEquals('potato', $result->directory); - $this->assertSame(1, preg_match('/^[a-f0-9]{32}$/', $result->filename)); + $this->assertSame(1, preg_match('/^[a-f0-9]{40}$/', $result->filename)); $this->assertTrue($media->fileExists()); } - public function test_it_errors_on_duplicate() + public function test_it_errors_on_duplicate(): void { $this->expectException(ImageManipulationException::class); $this->useFilesystem('tmp'); @@ -385,7 +390,7 @@ function (Image $image) { $imageManipulator->createImageVariant($media, 'test'); } - public function test_it_errors_on_duplicate_after_before_save() + public function test_it_errors_on_duplicate_after_before_save(): void { $this->expectException(ImageManipulationException::class); $this->useFilesystem('tmp'); @@ -419,7 +424,7 @@ function (Media $variant) { $imageManipulator->createImageVariant($media, 'test'); } - public function test_it_increments_on_duplicate() + public function test_it_increments_on_duplicate(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -453,7 +458,7 @@ function (Image $image) { $this->assertEquals('bar-test-2', $result->filename); } - public function test_it_increments_on_duplicate_after_before_save() + public function test_it_increments_on_duplicate_after_before_save(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -488,7 +493,7 @@ function (Media $variant) { $this->assertEquals('bar-1', $result->filename); } - public function test_it_skips_existing_variants() + public function test_it_skips_existing_variants(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -542,7 +547,7 @@ function (Image $image) { ); } - public function test_it_can_recreate_an_existing_variant() + public function test_it_can_recreate_an_existing_variant(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -594,7 +599,7 @@ function (Image $image) { $this->assertEquals($result->size, $result2->size); } - public function test_it_can_recreate_existing_variant_to_a_new_destination() + public function test_it_can_recreate_existing_variant_to_a_new_destination(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -646,7 +651,7 @@ function (Image $image) { $this->assertFalse($previousVariant->fileExists()); } - public function visibilityProvider() + public static function visibilityProvider(): array { return [ ['uploads', 'public', null, true], @@ -666,7 +671,7 @@ public function test_variant_created_with_visibility( string $originalVisibility, ?string $manipulationVisibility, bool $expectedVisibility - ) { + ): void { $this->useFilesystem($disk); $this->useDatabase(); @@ -694,11 +699,70 @@ public function test_variant_created_with_visibility( $this->assertSame($expectedVisibility, $result->isVisible()); } - public function getManipulator(): ImageManipulator + public function test_it_can_optimize_images_after_manipulation(): void { - return new ImageManipulator( - new ImageManager(['driver' => 'gd']), - app(FilesystemManager::class) + if (shell_exec('command -v pngquant') === null) { + $this->markTestSkipped('pngquant is not installed.'); + } + if (shell_exec('command -v optipng') === null) { + $this->markTestSkipped('optipng is not installed.'); + } + + $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()); + $originalSize = filesize($this->sampleFilePath()); + + $manipulation = ImageManipulation::make($this->getMockCallable()) + ->outputPngFormat() + ->optimize([ + Pngquant::class => [ + '--quality=85', + '--force', + '--skip-if-larger' + ], + Optipng::class => [ + '-i0', + '-o2', + '-quiet', + ] + ]); + + $imageManipulator = $this->getManipulator(); + $imageManipulator->defineVariant('test', $manipulation); + $result = $imageManipulator->createImageVariant($media, 'test'); + + $this->assertTrue($result->exists); + $this->assertLessThan($originalSize, $result->size); + } + + public function getManipulator(): ImageManipulator + { + if (class_exists(GdDriver::class)) { + // Intervention Image >=3.0 + return new ImageManipulator( + new ImageManager(new GdDriver()), + app(FilesystemManager::class), + app(ImageOptimizer::class) + ); + } else { + // Intervention Image <3.0 + return new ImageManipulator( + new ImageManager(['driver' => 'gd']), + app(FilesystemManager::class), + app(ImageOptimizer::class) + ); + } } } diff --git a/tests/Integration/ImageOptimizerTest.php b/tests/Integration/ImageOptimizerTest.php new file mode 100644 index 00000000..bdfb84ac --- /dev/null +++ b/tests/Integration/ImageOptimizerTest.php @@ -0,0 +1,28 @@ +sampleFilePath())); + + $imageOptimizer = new ImageOptimizer(); + $optimizerChain = $this->createMock(OptimizerChain::class); + $optimizerChain->expects($this->once()) + ->method('optimize'); + + $optimizedStream = $imageOptimizer->optimizeImage($imageStream, $optimizerChain); + $imageStream->rewind(); + $optimizedStream->rewind(); + + $this->assertNotSame($imageStream, $optimizedStream); + $this->assertEquals($imageStream->getContents(), $optimizedStream->getContents()); + } +} diff --git a/tests/Integration/Jobs/CreateImageVariantsTest.php b/tests/Integration/Jobs/CreateImageVariantsTest.php index cdb9a18e..a06f2f87 100644 --- a/tests/Integration/Jobs/CreateImageVariantsTest.php +++ b/tests/Integration/Jobs/CreateImageVariantsTest.php @@ -11,7 +11,7 @@ class CreateImageVariantsTest extends TestCase { - public function test_it_will_trigger_image_manipulation() + public function test_it_will_trigger_image_manipulation(): void { $model = $this->makeMedia(['aggregate_type' => 'image']); $variant = 'foo'; @@ -26,14 +26,14 @@ public function test_it_will_trigger_image_manipulation() ->willReturn($this->createMock(ImageManipulation::class)); $manipulator->expects($this->once()) ->method('createImageVariant') - ->withConsecutive([$model, $variant, false]); + ->with(...$this->withConsecutive([$model, $variant, false])); app()->instance(ImageManipulator::class, $manipulator); $job = new CreateImageVariants($model, $variant); $job->handle(); } - public function test_it_will_trigger_image_manipulation_multiple() + public function test_it_will_trigger_image_manipulation_multiple(): void { $model1 = $this->makeMedia(['aggregate_type' => 'image']); $model2 = $this->makeMedia(['aggregate_type' => 'image']); @@ -43,19 +43,19 @@ public function test_it_will_trigger_image_manipulation_multiple() $manipulator = $this->createMock(ImageManipulator::class); $manipulator->expects($this->exactly(2)) ->method('validateMedia') - ->withConsecutive([$model1], [$model2]); + ->with(...$this->withConsecutive([$model1], [$model2])); $manipulator->expects($this->exactly(2)) ->method('getVariantDefinition') - ->withConsecutive([$variant1], [$variant2]) + ->with(...$this->withConsecutive([$variant1], [$variant2])) ->willReturn($this->createMock(ImageManipulation::class)); $manipulator->expects($this->exactly(4)) ->method('createImageVariant') - ->withConsecutive( + ->with(...$this->withConsecutive( [$model1, $variant1, false], [$model1, $variant2, false], [$model2, $variant1, false], [$model2, $variant2, false] - ); + )); app()->instance(ImageManipulator::class, $manipulator); $job = new CreateImageVariants( @@ -65,7 +65,7 @@ public function test_it_will_trigger_image_manipulation_multiple() $job->handle(); } - public function test_it_will_trigger_image_manipulation_recreate() + public function test_it_will_trigger_image_manipulation_recreate(): void { $model = $this->makeMedia(['aggregate_type' => 'image']); $variant1 = 'foo'; @@ -77,21 +77,21 @@ public function test_it_will_trigger_image_manipulation_recreate() ->with($model); $manipulator->expects($this->exactly(2)) ->method('getVariantDefinition') - ->withConsecutive([$variant1], [$variant2]) + ->with(...$this->withConsecutive([$variant1], [$variant2])) ->willReturn($this->createMock(ImageManipulation::class)); $manipulator->expects($this->exactly(2)) ->method('createImageVariant') - ->withConsecutive( + ->with(...$this->withConsecutive( [$model, $variant1, true], [$model, $variant2, true] - ); + )); app()->instance(ImageManipulator::class, $manipulator); $job = new CreateImageVariants($model, [$variant1, $variant2], true); $job->handle(); } - public function test_it_will_serialize_models() + public function test_it_will_serialize_models(): void { $this->useDatabase(); $model = $this->createMedia(['aggregate_type' => 'image']); @@ -103,7 +103,7 @@ public function test_it_will_serialize_models() ->with($model); $manipulator->expects($this->any()) ->method('getVariantDefinition') - ->withConsecutive([$variant]) + ->with(...$this->withConsecutive([$variant])) ->willReturn($this->createMock(ImageManipulation::class)); app()->instance(ImageManipulator::class, $manipulator); diff --git a/tests/Integration/MediaTest.php b/tests/Integration/MediaTest.php index 264d8c9d..a98353b9 100644 --- a/tests/Integration/MediaTest.php +++ b/tests/Integration/MediaTest.php @@ -19,7 +19,7 @@ class MediaTest extends TestCase { - public function test_it_has_path_accessors() + public function test_it_has_path_accessors(): void { $media = $this->makeMedia( [ @@ -41,7 +41,7 @@ public function test_it_has_path_accessors() $this->assertEquals('jpg', $media->extension); } - public function test_it_can_be_queried_by_directory() + public function test_it_can_be_queried_by_directory(): void { $this->useDatabase(); @@ -54,7 +54,7 @@ public function test_it_can_be_queried_by_directory() $this->assertEquals(1, Media::inDirectory('tmp', 'foo/baz')->count()); } - public function test_it_can_be_queried_by_directory_recursively() + public function test_it_can_be_queried_by_directory_recursively(): void { $this->useDatabase(); @@ -68,7 +68,7 @@ public function test_it_can_be_queried_by_directory_recursively() $this->assertEquals(1, Media::inDirectory('tmp', 'foo/bar/baz', true)->count()); } - public function test_it_can_be_queried_by_basename() + public function test_it_can_be_queried_by_basename(): void { $this->useDatabase(); @@ -79,7 +79,7 @@ public function test_it_can_be_queried_by_basename() $this->assertEquals(99, Media::whereBasename('baz.bat')->first()->id); } - public function test_it_can_be_queried_by_path_on_disk() + public function test_it_can_be_queried_by_path_on_disk(): void { $this->useDatabase(); @@ -98,7 +98,7 @@ public function test_it_can_be_queried_by_path_on_disk() ); } - public function test_it_can_be_queried_by_path_on_disk_when_directory_is_empty() + public function test_it_can_be_queried_by_path_on_disk_when_directory_is_empty(): void { $this->useDatabase(); @@ -114,7 +114,7 @@ public function test_it_can_be_queried_by_path_on_disk_when_directory_is_empty() $this->assertEquals(4, Media::forPathOnDisk('tmp', 'bat.jpg')->first()->id); } - public function test_it_can_view_human_readable_file_size() + public function test_it_can_view_human_readable_file_size(): void { $media = $this->makeMedia(['size' => 0]); @@ -127,7 +127,7 @@ public function test_it_can_view_human_readable_file_size() $this->assertEquals('1.1 MB', $media->readableSize(2)); } - public function test_it_can_be_checked_for_public_visibility() + public function test_it_can_be_checked_for_public_visibility(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -146,7 +146,7 @@ public function test_it_can_be_checked_for_public_visibility() $this->assertTrue($media->isPubliclyAccessible()); } - public function test_it_can_be_checked_for_public_visibility_s3() + public function test_it_can_be_checked_for_public_visibility_s3(): void { if (!$this->s3ConfigLoaded()) { $this->markTestSkipped('S3 Credentials not available.'); @@ -172,7 +172,7 @@ public function test_it_can_be_checked_for_public_visibility_s3() } } - public function test_it_can_generate_a_url_to_the_local_file() + public function test_it_can_generate_a_url_to_the_local_file(): void { $media = $this->makeMedia( [ @@ -186,7 +186,7 @@ public function test_it_can_generate_a_url_to_the_local_file() $this->assertEquals('http://localhost/uploads/foo/bar/baz.jpg', $media->getUrl()); } - public function test_it_can_generate_a_custom_url_to_the_local_file() + public function test_it_can_generate_a_custom_url_to_the_local_file(): void { $this->app['config']->set('filesystems.disks.uploads.url', 'http://example.com'); $media = $this->makeMedia( @@ -201,7 +201,7 @@ public function test_it_can_generate_a_custom_url_to_the_local_file() $this->assertEquals('http://example.com/foo/bar/baz.jpg', $media->getUrl()); } - public function test_it_can_generate_a_url_to_the_file_on_s3() + public function test_it_can_generate_a_url_to_the_file_on_s3(): void { if (!$this->s3ConfigLoaded()) { $this->markTestSkipped('S3 Credentials not available.'); @@ -230,7 +230,7 @@ public function test_it_can_generate_a_url_to_the_file_on_s3() } } - public function test_it_can_check_if_its_file_exists() + public function test_it_can_check_if_its_file_exists(): void { $this->useFilesystem('tmp'); @@ -240,7 +240,7 @@ public function test_it_can_check_if_its_file_exists() $this->assertTrue($media->fileExists()); } - public function test_it_can_be_moved_on_disk() + public function test_it_can_be_moved_on_disk(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -265,7 +265,7 @@ public function test_it_can_be_moved_on_disk() $this->assertTrue($media->fileExists()); } - public function test_it_can_be_copied_on_disk() + public function test_it_can_be_copied_on_disk(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -299,7 +299,7 @@ public function test_it_can_be_copied_on_disk() $media->copyTo('alpha', 'test'); } - public function test_it_throws_an_exception_if_moving_to_existing_file() + public function test_it_throws_an_exception_if_moving_to_existing_file(): void { $this->useFilesystem('tmp'); @@ -326,7 +326,7 @@ public function test_it_throws_an_exception_if_moving_to_existing_file() $media1->move('', 'bar.baz'); } - public function test_it_can_be_moved_to_another_disk_public() + public function test_it_can_be_moved_to_another_disk_public(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -353,7 +353,7 @@ public function test_it_can_be_moved_to_another_disk_public() $this->assertTrue($media->isVisible()); } - public function test_it_can_be_moved_to_another_disk_private() + public function test_it_can_be_moved_to_another_disk_private(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -380,7 +380,7 @@ public function test_it_can_be_moved_to_another_disk_private() $this->assertFalse($media->isVisible()); } - public function test_it_can_be_copied_to_another_disk_public() + public function test_it_can_be_copied_to_another_disk_public(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -411,7 +411,7 @@ public function test_it_can_be_copied_to_another_disk_public() $this->assertTrue($media->isVisible()); } - public function test_it_can_be_copied_to_another_disk_private() + public function test_it_can_be_copied_to_another_disk_private(): void { $this->useFilesystem('tmp'); $this->useFilesystem('uploads'); @@ -442,7 +442,7 @@ public function test_it_can_be_copied_to_another_disk_private() $this->assertFalse($media->isVisible()); } - public function test_it_can_access_file_contents() + public function test_it_can_access_file_contents(): void { $this->useFilesystem('tmp'); @@ -456,7 +456,7 @@ public function test_it_can_access_file_contents() $this->assertEquals('

Hello World

', $media->contents()); } - public function test_it_deletes_its_file_on_deletion() + public function test_it_deletes_its_file_on_deletion(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -477,7 +477,7 @@ public function test_it_deletes_its_file_on_deletion() $this->assertFalse(file_exists($path)); } - public function test_it_cascades_relationship_on_delete() + public function test_it_cascades_relationship_on_delete(): void { $this->useDatabase(); @@ -489,7 +489,7 @@ public function test_it_cascades_relationship_on_delete() $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_doesnt_cascade_relationship_on_soft_delete() + public function test_it_doesnt_cascade_relationship_on_soft_delete(): void { $this->useDatabase(); @@ -501,7 +501,7 @@ public function test_it_doesnt_cascade_relationship_on_soft_delete() $this->assertEquals(1, $mediable->getMedia('foo')->count()); } - public function test_it_cascades_relationships_on_soft_delete_with_config() + public function test_it_cascades_relationships_on_soft_delete_with_config(): void { $this->useDatabase(); @@ -515,7 +515,7 @@ public function test_it_cascades_relationships_on_soft_delete_with_config() $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_cascades_relationship_on_force_delete() + public function test_it_cascades_relationship_on_force_delete(): void { $this->useDatabase(); @@ -527,7 +527,7 @@ public function test_it_cascades_relationship_on_force_delete() $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_retrieves_models_via_custom_mediables_table() + public function test_it_retrieves_models_via_custom_mediables_table(): void { $this->useDatabase(); @@ -542,7 +542,7 @@ public function test_it_retrieves_models_via_custom_mediables_table() } public function test_it_cascades_relationships_on_soft_delete_with_config_via_custom_mediables_table( - ) { + ): void { $this->useDatabase(); config()->set('mediable.mediables_table', 'prefixed_mediables'); @@ -557,7 +557,7 @@ public function test_it_cascades_relationships_on_soft_delete_with_config_via_cu $this->assertEmpty(DB::table('prefixed_mediables')->get()); } - public function test_it_can_stream_contents() + public function test_it_can_stream_contents(): void { $this->useFilesystem('tmp'); @@ -576,7 +576,7 @@ public function test_it_can_stream_contents() $this->assertEquals('test', $stream->getContents()); } - public function test_it_can_detect_variant_status() + public function test_it_can_detect_variant_status(): void { $media = $this->makeMedia( [ @@ -607,7 +607,7 @@ public function test_it_can_detect_variant_status() $this->assertFalse($media->isVariant('foo')); } - public function test_it_can_be_made_a_variant_of_another() + public function test_it_can_be_made_a_variant_of_another(): void { $this->useDatabase(); @@ -639,7 +639,7 @@ public function test_it_can_be_made_a_variant_of_another() $this->assertEquals('foo', $media2->variant_name); } - public function test_it_throws_if_cant_find_new_original() + public function test_it_throws_if_cant_find_new_original(): void { $this->expectException(ModelNotFoundException::class); $this->useDatabase(); @@ -654,7 +654,7 @@ public function test_it_throws_if_cant_find_new_original() $media->makeVariantOf(9999, 'not_found'); } - public function test_it_can_be_queried_by_variant_status() + public function test_it_can_be_queried_by_variant_status(): void { $this->useDatabase(); $media1 = $this->createMedia( @@ -694,7 +694,7 @@ public function test_it_can_be_queried_by_variant_status() ); } - public function test_it_can_find_other_variants() + public function test_it_can_find_other_variants(): void { $media1 = $this->makeMedia( [ @@ -768,7 +768,7 @@ public function test_it_can_find_other_variants() $this->assertEquals($all, $media3->getAllVariantsAndSelf()); } - public function test_it_generates_temporary_urls() + public function test_it_generates_temporary_urls(): void { $media = $this->makeMedia(); $expiry = Carbon::now(); @@ -789,7 +789,7 @@ public function test_it_generates_temporary_urls() $this->assertEquals($url, $media->getTemporaryUrl($expiry)); } - public function test_it_throws_for_unsupported_temporary_urls() + public function test_it_throws_for_unsupported_temporary_urls(): void { $this->expectException(MediaUrlException::class); $media = $this->makeMedia(); diff --git a/tests/Integration/MediaUploaderTest.php b/tests/Integration/MediaUploaderTest.php index b6f65888..7b5a3989 100644 --- a/tests/Integration/MediaUploaderTest.php +++ b/tests/Integration/MediaUploaderTest.php @@ -2,34 +2,38 @@ namespace Plank\Mediable\Tests\Integration; +use GuzzleHttp\Psr7\Utils; +use Intervention\Image\Image; use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; use Plank\Mediable\Exceptions\MediaUpload\FileExistsException; use Plank\Mediable\Exceptions\MediaUpload\FileNotFoundException; use Plank\Mediable\Exceptions\MediaUpload\FileNotSupportedException; use Plank\Mediable\Exceptions\MediaUpload\FileSizeException; use Plank\Mediable\Exceptions\MediaUpload\ForbiddenException; +use Plank\Mediable\Exceptions\MediaUpload\InvalidHashException; +use Plank\Mediable\ImageManipulation; +use Plank\Mediable\ImageManipulator; use Plank\Mediable\Media; use Plank\Mediable\MediaUploader; use Plank\Mediable\Facades\MediaUploader as Facade; use Plank\Mediable\SourceAdapters\SourceAdapterInterface; -use Plank\Mediable\Stream; use Plank\Mediable\Tests\Mocks\MediaSubclass; use Plank\Mediable\Tests\TestCase; use stdClass; class MediaUploaderTest extends TestCase { - public function test_it_can_be_instantiated_via_the_container() + public function test_it_can_be_instantiated_via_the_container(): void { $this->assertInstanceOf(MediaUploader::class, app('mediable.uploader')); } - public function test_it_can_be_instantiated_via_facade() + public function test_it_can_be_instantiated_via_facade(): void { $this->assertInstanceOf(MediaUploader::class, Facade::getFacadeRoot()); } - public function test_facade_instantiates_unique_instances() + public function test_facade_instantiates_unique_instances(): void { /** @var MediaUploader $uploader1 */ $uploader1 = Facade::getFacadeRoot(); @@ -47,13 +51,13 @@ public function test_facade_instantiates_unique_instances() ); } - public function test_facade_is_mockable() + public function test_facade_is_mockable(): void { Facade::shouldReceive('upload')->once(); Facade::upload(); } - public function test_it_can_set_on_duplicate_behavior_via_facade() + public function test_it_can_set_on_duplicate_behavior_via_facade(): void { $uploader = Facade::onDuplicateError(); $this->assertEquals( @@ -86,7 +90,7 @@ public function test_it_can_set_on_duplicate_behavior_via_facade() ); } - public function test_it_sets_options() + public function test_it_sets_options(): void { $uploader = $this->getUploader(); $this->assertEquals( @@ -107,7 +111,7 @@ public function test_it_sets_options() ); } - public function test_it_can_determine_media_type_by_extension_and_mime() + public function test_it_can_determine_media_type_by_extension_and_mime(): void { $uploader = $this->getUploader(); $uploader->setTypeDefinition('foo', ['text/foo'], ['foo']); @@ -133,7 +137,7 @@ public function test_it_can_determine_media_type_by_extension_and_mime() ); } - public function test_it_throws_exception_for_type_mismatch() + public function test_it_throws_exception_for_type_mismatch(): void { $uploader = $this->getUploader(); $uploader->setTypeDefinition('foo', ['text/foo'], ['foo']); @@ -143,7 +147,7 @@ public function test_it_throws_exception_for_type_mismatch() $uploader->inferAggregateType('text/foo', 'bar'); } - public function test_it_validates_allowed_types() + public function test_it_validates_allowed_types(): void { $uploader = $this->getUploader(); $uploader->setTypeDefinition('foo', ['text/foo'], ['foo']); @@ -166,7 +170,7 @@ public function test_it_validates_allowed_types() $uploader->inferAggregateType('text/foo', 'bar'); } - public function test_it_infers_type_case_insensitive() + public function test_it_infers_type_case_insensitive(): void { $uploader = $this->getUploader(); $uploader->setTypeDefinition('foo', ['TeXT/foo'], ['FOo']); @@ -177,7 +181,7 @@ public function test_it_infers_type_case_insensitive() ); } - public function test_it_can_restrict_to_known_types() + public function test_it_can_restrict_to_known_types(): void { $uploader = $this->getUploader(); @@ -191,14 +195,14 @@ public function test_it_can_restrict_to_known_types() $uploader->inferAggregateType('text/foo', 'bar'); } - public function test_it_throws_exception_for_non_existent_disk() + public function test_it_throws_exception_for_non_existent_disk(): void { $uploader = $this->getUploader(); $this->expectException(ConfigurationException::class); $uploader->toDisk('abc'); } - public function test_it_throws_exception_for_disallowed_disk() + public function test_it_throws_exception_for_disallowed_disk(): void { $uploader = $this->getUploader(); config()->set('filesystems.disks.foo', []); @@ -206,7 +210,7 @@ public function test_it_throws_exception_for_disallowed_disk() $uploader->toDisk('foo'); } - public function test_it_can_change_model_class() + public function test_it_can_change_model_class(): void { $uploader = $this->getUploader(); $method = $this->getPrivateMethod($uploader, 'makeModel'); @@ -214,14 +218,14 @@ public function test_it_can_change_model_class() $this->assertInstanceOf(MediaSubclass::class, $method->invoke($uploader)); } - public function test_it_throw_exception_for_invalid_model() + public function test_it_throw_exception_for_invalid_model(): void { $uploader = $this->getUploader(); $this->expectException(ConfigurationException::class); $uploader->setModelClass(stdClass::class); } - public function test_it_validates_source_is_set() + public function test_it_validates_source_is_set(): void { $uploader = $this->getUploader(); $method = $this->getPrivateMethod($uploader, 'verifySource'); @@ -230,34 +234,7 @@ public function test_it_validates_source_is_set() $method->invoke($uploader); } - public function test_it_validates_source_is_valid() - { - $uploader = $this->getUploader(); - $method = $this->getPrivateMethod($uploader, 'verifySource'); - - $source = $this->createMock(SourceAdapterInterface::class); - $source->method('valid')->willReturn(true); - $uploader->fromSource($source); - $method->invoke($uploader); - - $this->assertTrue(true); - } - - public function test_it_validates_source_is_invalid() - { - $uploader = $this->getUploader(); - $method = $this->getPrivateMethod($uploader, 'verifySource'); - - $source = $this->createMock(SourceAdapterInterface::class); - $source->method('valid')->willReturn(false); - $source->method('path')->willReturn(''); - $uploader->fromSource($source); - - $this->expectException(FileNotFoundException::class); - $method->invoke($uploader); - } - - public function test_it_validates_allowed_mime_types() + public function test_it_validates_allowed_mime_types(): void { $uploader = $this->getUploader(); $method = $this->getPrivateMethod($uploader, 'verifyMimeType'); @@ -279,7 +256,7 @@ public function test_it_validates_allowed_mime_types() $method->invoke($uploader, 'text/foo'); } - public function test_it_validates_allowed_extensions() + public function test_it_validates_allowed_extensions(): void { $uploader = $this->getUploader(); $method = $this->getPrivateMethod($uploader, 'verifyExtension'); @@ -293,7 +270,7 @@ public function test_it_validates_allowed_extensions() $method->invoke($uploader, 'foo'); } - public function test_it_validates_file_size() + public function test_it_validates_file_size(): void { $uploader = $this->getUploader(); $uploader->setMaximumSize(2); @@ -304,7 +281,7 @@ public function test_it_validates_file_size() $method->invoke($uploader, 3); } - public function test_it_can_disable_file_size_limits() + public function test_it_can_disable_file_size_limits(): void { $uploader = $this->getUploader(); $uploader->setMaximumSize(0); @@ -312,7 +289,7 @@ public function test_it_can_disable_file_size_limits() $this->assertEquals(99999, $method->invoke($uploader, 99999)); } - public function test_it_can_error_on_duplicate_files() + public function test_it_can_error_on_duplicate_files(): void { $uploader = $this->getUploader(); $uploader->setOnDuplicateBehavior(MediaUploader::ON_DUPLICATE_ERROR); @@ -321,28 +298,36 @@ public function test_it_can_error_on_duplicate_files() $method->invoke($uploader, new Media); } - public function test_it_sets_file_visibility() + public function test_it_sets_file_visibility(): void { $this->useDatabase(); $this->useFilesystem('tmp'); - $media1 = $this->getUploader()->fromSource($this->sampleFilePath()) + $media1 = $this->getUploader()->fromSource(TestCase::sampleFilePath()) ->toDestination('tmp', 'a') - ->makePrivate() ->upload(); + $this->assertFalse($media1->isVisible()); - $media2 = $this->getUploader()->fromSource($this->sampleFilePath()) + $media2 = $this->getUploader()->fromSource(TestCase::sampleFilePath()) ->toDestination('tmp', 'b') ->makePrivate() - ->makePublic() ->upload(); + $this->assertFalse($media2->isVisible()); - $this->assertFalse($media1->isVisible()); + $media3 = $this->getUploader()->fromSource(TestCase::sampleFilePath()) + ->toDestination('tmp', 'c') + ->makePrivate() + ->makePublic() + ->upload(); + $this->assertTrue($media3->isVisible()); - $this->assertTrue($media2->isVisible()); + $media1 = $this->getUploader()->fromSource(TestCase::sampleFilePath()) + ->toDestination('novisibility', 'a') + ->upload(); + $this->assertTrue($media1->isVisible()); } - public function test_it_can_replace_duplicate_files() + public function test_it_can_replace_duplicate_files(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -369,8 +354,8 @@ public function test_it_can_replace_duplicate_files() 'original_media_id' => $media->getKey() ] ); - $this->seedFileForMedia($media, $this->sampleFilePath()); - $this->seedFileForMedia($variant, $this->sampleFilePath()); + $this->seedFileForMedia($media, TestCase::sampleFilePath()); + $this->seedFileForMedia($variant, TestCase::sampleFilePath()); $method->invoke($uploader, $media); @@ -379,7 +364,7 @@ public function test_it_can_replace_duplicate_files() $this->assertTrue(file_exists($variant->getAbsolutePath())); } - public function test_it_can_replace_duplicate_files_and_variants() + public function test_it_can_replace_duplicate_files_and_variants(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -404,8 +389,8 @@ public function test_it_can_replace_duplicate_files_and_variants() 'original_media_id' => $media->getKey() ] ); - $this->seedFileForMedia($media, $this->sampleFilePath()); - $this->seedFileForMedia($variant, $this->sampleFilePath()); + $this->seedFileForMedia($media, TestCase::sampleFilePath()); + $this->seedFileForMedia($variant, TestCase::sampleFilePath()); $method->invoke($uploader, $media); @@ -414,7 +399,7 @@ public function test_it_can_replace_duplicate_files_and_variants() $this->assertFalse(file_exists($variant->getAbsolutePath())); } - public function test_it_can_update_duplicate_files() + public function test_it_can_update_duplicate_files(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -429,13 +414,13 @@ public function test_it_can_update_duplicate_files() ] ); - $this->seedFileForMedia($media, fopen($this->sampleFilePath(), 'r')); + $this->seedFileForMedia($media, fopen(TestCase::sampleFilePath(), 'r')); $creaetdAt = $media->created_at; $updatedAt = $media->updated_at; sleep(1); // required to check the update time is different - $result = Facade::fromSource($this->sampleFilePath()) + $result = Facade::fromSource(TestCase::sampleFilePath()) ->onDuplicateUpdate() ->toDestination('tmp', '')->upload(); @@ -446,7 +431,7 @@ public function test_it_can_update_duplicate_files() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_can_update_duplicate_files_when_model_not_found() + public function test_it_can_update_duplicate_files_when_model_not_found(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -463,17 +448,17 @@ public function test_it_can_update_duplicate_files_when_model_not_found() $this->seedFileForMedia($media, 'foo'); - $result = Facade::fromSource($this->sampleFilePath()) + $result = Facade::fromSource(TestCase::sampleFilePath()) ->onDuplicateUpdate() ->toDestination('tmp', '')->upload(); $this->assertEquals( - file_get_contents($this->sampleFilePath()), + file_get_contents(TestCase::sampleFilePath()), file_get_contents($result->getAbsolutePath()) ); } - public function test_it_can_increment_filename_on_duplicate_files() + public function test_it_can_increment_filename_on_duplicate_files(): void { $uploader = $this->getUploader()->onDuplicateIncrement(); $method = $this->getPrivateMethod($uploader, 'handleDuplicate'); @@ -493,12 +478,12 @@ public function test_it_can_increment_filename_on_duplicate_files() $this->assertEquals('duplicate-1', $media->filename); } - public function test_it_uploads_files() + public function test_it_uploads_files(): void { $this->useDatabase(); $this->useFilesystem('tmp'); - $media = Facade::fromSource($this->sampleFilePath()) + $media = Facade::fromSource(TestCase::sampleFilePath()) ->toDestination('tmp', 'foo') ->useFilename('bar') ->upload(); @@ -512,12 +497,12 @@ public function test_it_uploads_files() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_imports_string_contents() + public function test_it_imports_string_contents(): void { $this->useDatabase(); $this->useFilesystem('tmp'); - $string = file_get_contents($this->sampleFilePath()); + $string = file_get_contents(TestCase::sampleFilePath()); $media = Facade::fromString($string) ->toDestination('tmp', 'foo') @@ -533,12 +518,12 @@ public function test_it_imports_string_contents() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_imports_file_stream_contents() + public function test_it_imports_file_stream_contents(): void { $this->useDatabase(); $this->useFilesystem('tmp'); - $resource = fopen(realpath($this->sampleFilePath()), 'r'); + $resource = fopen(realpath(TestCase::sampleFilePath()), 'r'); $media = Facade::fromSource($resource) ->toDestination('tmp', 'foo') @@ -554,12 +539,12 @@ public function test_it_imports_file_stream_contents() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_imports_http_stream_contents() + public function test_it_imports_http_stream_contents(): void { $this->useDatabase(); $this->useFilesystem('tmp'); - $resource = fopen($this->remoteFilePath(), 'r'); + $resource = fopen(TestCase::remoteFilePath(), 'r'); $media = Facade::fromSource($resource) ->toDestination('tmp', 'foo') @@ -575,12 +560,12 @@ public function test_it_imports_http_stream_contents() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_imports_stream_objects() + public function test_it_imports_stream_objects(): void { $this->useDatabase(); $this->useFilesystem('tmp'); - $stream = new Stream(fopen($this->remoteFilePath(), 'r')); + $stream = Utils::streamFor(fopen(TestCase::remoteFilePath(), 'r')); $media = Facade::fromSource($stream) ->toDestination('tmp', 'foo') @@ -596,7 +581,7 @@ public function test_it_imports_stream_objects() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_imports_existing_files() + public function test_it_imports_existing_files(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -621,7 +606,7 @@ public function test_it_imports_existing_files() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_imports_existing_files_with_uppercase() + public function test_it_imports_existing_files_with_uppercase(): void { $this->useFilesystem('tmp'); $this->useDatabase(); @@ -646,7 +631,7 @@ public function test_it_imports_existing_files_with_uppercase() $this->assertEquals('image', $media->aggregate_type); } - public function test_it_updates_existing_media() + public function test_it_updates_existing_media(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -670,7 +655,7 @@ public function test_it_updates_existing_media() $this->assertEquals(self::TEST_FILE_SIZE, $media->size); } - public function test_it_replaces_existing_media() + public function test_it_replaces_existing_media(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -684,25 +669,25 @@ public function test_it_replaces_existing_media() ); $this->seedFileForMedia($media, $this->sampleFile()); - $result = Facade::fromSource($this->alternateFilePath())->replace($media); + $result = Facade::fromSource(TestCase::alternateFilePath())->replace($media); $media = $media->fresh(); $this->assertEquals($result->getKey(), $media->getKey()); $this->assertEquals(4181, $media->size); } - public function test_it_throws_exception_when_importing_missing_file() + public function test_it_throws_exception_when_importing_missing_file(): void { $this->expectException(FileNotFoundException::class); Facade::import('tmp', 'non', 'existing', 'jpg'); } - public function test_it_use_hash_for_filename() + public function test_it_use_hash_for_filename(): void { $this->useFilesystem('tmp'); $this->useDatabase(); - $media = Facade::fromSource($this->sampleFilePath()) + $media = Facade::fromSource(TestCase::sampleFilePath()) ->toDestination('tmp', 'foo') ->useHashForFilename() ->upload(); @@ -710,12 +695,25 @@ public function test_it_use_hash_for_filename() $this->assertEquals('3ef5e70366086147c2695325d79a25cc', $media->filename); } - public function test_it_uploads_files_with_altered_model() + public function test_it_use_arbitrary_hash_algo_for_filename(): void + { + $this->useFilesystem('tmp'); + $this->useDatabase(); + + $media = Facade::fromSource(TestCase::sampleFilePath()) + ->toDestination('tmp', 'foo') + ->useHashForFilename('sha1') + ->upload(); + + $this->assertEquals('5e96e1fa58067853219c4cb6d3c1ce01cc5cc8ce', $media->filename); + } + + public function test_it_uploads_files_with_altered_model(): void { $this->useDatabase(); $this->useFilesystem('tmp'); - $media = Facade::fromSource($this->sampleFilePath()) + $media = Facade::fromSource(TestCase::sampleFilePath()) ->toDestination('tmp', 'foo') ->useFilename('bar') ->beforeSave( @@ -735,7 +733,7 @@ function ($model) { $this->assertEquals(9876, $media->id); } - public function test_it_uploads_files_with_altered_destination() + public function test_it_uploads_files_with_altered_destination(): void { $this->useDatabase(); $this->useFilesystem('tmp'); @@ -749,9 +747,9 @@ public function test_it_uploads_files_with_altered_destination() ] ); - $this->seedFileForMedia($media, fopen($this->alternateFilePath(), 'r')); + $this->seedFileForMedia($media, fopen(TestCase::alternateFilePath(), 'r')); - $media = Facade::fromSource($this->sampleFilePath()) + $media = Facade::fromSource(TestCase::sampleFilePath()) ->toDestination('tmp', 'foo') ->useFilename('bar') ->onDuplicateReplace() @@ -771,11 +769,138 @@ function (Media $model) { $this->assertEquals(self::TEST_FILE_SIZE, $media->size); $this->assertEquals('image', $media->aggregate_type); $this->assertEquals( - file_get_contents($this->sampleFilePath()), + file_get_contents(TestCase::sampleFilePath()), $media->contents() ); } + public function test_it_applies_alt(): void + { + $this->useDatabase(); + $this->useFilesystem('tmp'); + + $media = Facade::fromSource(TestCase::sampleFilePath()) + ->toDestination('tmp', 'foo') + ->useFilename('bar') + ->withAltAttribute('This is an alt text') + ->upload(); + + $this->assertEquals('This is an alt text', $media->alt); + } + + public function test_it_applies_alt_to_existing_media(): void + { + $this->useDatabase(); + $this->useFilesystem('tmp'); + + $media = $this->createMedia( + [ + 'disk' => 'tmp', + 'directory' => 'foo', + 'filename' => 'bar', + 'extension' => 'png', + 'mime_type' => 'image/png', + 'size' => 999, + ] + ); + $this->seedFileForMedia($media, $this->sampleFile()); + + $result = Facade::fromSource(TestCase::sampleFilePath()) + ->toDestination('tmp', 'foo') + ->useFilename('bar') + ->withAltAttribute('This is an alt text') + ->replace($media); + + $this->assertEquals('This is an alt text', $result->alt); + } + + public function test_it_manipulates_images(): void + { + $this->useDatabase(); + $this->useFilesystem('tmp'); + + $manipulation = ImageManipulation::make( + function (Image $image) { + $image->resize(16, 16); + } + )->outputJpegFormat(); + + app(ImageManipulator::class)->defineVariant( + 'foo', + $manipulation + ); + + $media = Facade::fromSource(TestCase::sampleFilePath()) + ->toDestination('tmp', 'foo') + ->useFilename('bar') + ->applyImageManipulation('foo') + ->upload(); + + $this->assertInstanceOf(Media::class, $media); + $this->assertTrue($media->fileExists()); + $this->assertEquals('tmp', $media->disk); + $this->assertEquals('foo/bar.jpg', $media->getDiskPath()); + $this->assertEquals('image/jpeg', $media->mime_type); + $this->assertEquals('image', $media->aggregate_type); + $this->assertTrue( + $media->size >= 933 // intervention/image <3.0 + && $media->size <= 951 // intervention/image >=3.0 + ); + } + + public function test_it_ignores_manipulations_for_non_images(): void + { + $this->useDatabase(); + $this->useFilesystem('tmp'); + + $callback = $this->getMockCallable(); + $callback->expects($this->never())->method('__invoke'); + + $manipulation = ImageManipulation::make($callback); + + $media = Facade::fromSource("data:text/plain;base64," . base64_encode('foo')) + ->toDestination('tmp', 'foo') + ->useFilename('bar') + ->applyImageManipulation($manipulation) + ->upload(); + + $this->assertInstanceOf(Media::class, $media); + $this->assertTrue($media->fileExists()); + $this->assertEquals('tmp', $media->disk); + $this->assertEquals('foo/bar.txt', $media->getDiskPath()); + $this->assertEquals('text/plain', $media->mime_type); + $this->assertEquals('document', $media->aggregate_type); + $this->assertEquals(3, $media->size); + } + + public function test_it_validates_hashes(): void + { + $this->useDatabase(); + $this->useFilesystem('tmp'); + + $media = Facade::fromSource(TestCase::sampleFilePath()) + ->toDestination('tmp', 'foo') + ->useFilename('bar') + ->validateHash('3ef5e70366086147c2695325d79a25cc', 'md5') + ->validateHash('5e96e1fa58067853219c4cb6d3c1ce01cc5cc8ce', 'sha1') + ->upload(); + + $this->assertInstanceOf(Media::class, $media); + $this->assertTrue($media->fileExists()); + } + + public function test_it_validates_md5_hash_failure(): void + { + $this->expectException(InvalidHashException::class); + + Facade::fromSource(TestCase::sampleFilePath()) + ->toDestination('tmp', 'foo') + ->useFilename('bar') + ->validateHash('3ef5e70366086147c2695325d79a25cc', 'md5') + ->validateHash('abcdefabcdef', 'sha1') + ->upload(); + } + protected function getUploader(): MediaUploader { return app('mediable.uploader'); diff --git a/tests/Integration/MediableCollectionTest.php b/tests/Integration/MediableCollectionTest.php index 892aa4d2..5daafdfd 100644 --- a/tests/Integration/MediableCollectionTest.php +++ b/tests/Integration/MediableCollectionTest.php @@ -16,7 +16,7 @@ public function setUp(): void $this->useDatabase(); } - public function test_it_can_lazy_eager_load_media() + public function test_it_can_lazy_eager_load_media(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -29,7 +29,7 @@ public function test_it_can_lazy_eager_load_media() $this->assertFalse($collection[0]->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_by_tag() + public function test_it_can_lazy_eager_load_media_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -47,7 +47,7 @@ public function test_it_can_lazy_eager_load_media_by_tag() $this->assertFalse($collection[0]->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_by_tag_match_all() + public function test_it_can_lazy_eager_load_media_by_tag_match_all(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -70,7 +70,7 @@ public function test_it_can_lazy_eager_load_media_by_tag_match_all() $this->assertFalse($collection[0]->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_with_variants() + public function test_it_can_lazy_eager_load_media_with_variants(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -89,7 +89,7 @@ public function test_it_can_lazy_eager_load_media_with_variants() $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_with_variants_by_tag() + public function test_it_can_lazy_eager_load_media_with_variants_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -111,7 +111,7 @@ public function test_it_can_lazy_eager_load_media_with_variants_by_tag() $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_with_relations_by_tag_match_all() + public function test_it_can_lazy_eager_load_media_with_relations_by_tag_match_all(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -148,7 +148,7 @@ public function test_it_can_lazy_eager_load_media_with_relations_by_tag_match_al $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); } - public function testDelete() + public function testDelete(): void { $mediable1 = factory(SampleMediable::class)->create(['id' => 1]); $mediable2 = factory(SampleMediable::class)->create(['id' => 2]); diff --git a/tests/Integration/MediableTest.php b/tests/Integration/MediableTest.php index ea2f028f..dc98a707 100644 --- a/tests/Integration/MediableTest.php +++ b/tests/Integration/MediableTest.php @@ -17,7 +17,7 @@ public function setUp(): void $this->useDatabase(); } - public function test_it_can_attach_and_retrieve_media_by_a_tag() + public function test_it_can_attach_and_retrieve_media_by_a_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 2]); @@ -29,7 +29,7 @@ public function test_it_can_attach_and_retrieve_media_by_a_tag() $this->assertEquals([2], $result->pluck('id')->toArray()); } - public function test_it_can_attach_to_numeric_tags() + public function test_it_can_attach_to_numeric_tags(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 2]); @@ -41,7 +41,7 @@ public function test_it_can_attach_to_numeric_tags() $this->assertEquals([2], $result->pluck('id')->toArray()); } - public function test_it_can_attach_one_media_to_multiple_tags() + public function test_it_can_attach_one_media_to_multiple_tags(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 2]); @@ -58,8 +58,8 @@ public function test_it_can_attach_one_media_to_multiple_tags() ); } - public function test_it_can_attach_multiple_media_to_multiple_tags_simultaneously() - { + public function test_it_can_attach_multiple_media_to_multiple_tags_simultaneously( + ): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(); $media2 = factory(Media::class)->create(); @@ -70,7 +70,7 @@ public function test_it_can_attach_multiple_media_to_multiple_tags_simultaneousl $this->assertCount(2, $mediable->getMedia('bar')); } - public function test_it_can_find_the_first_media() + public function test_it_can_find_the_first_media(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -82,7 +82,7 @@ public function test_it_can_find_the_first_media() $this->assertEquals(1, $mediable->firstMedia('foo')->id); } - public function test_it_can_find_the_last_media() + public function test_it_can_find_the_last_media(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -94,7 +94,7 @@ public function test_it_can_find_the_last_media() $this->assertEquals(2, $mediable->lastMedia('foo')->id); } - public function test_it_can_find_media_matching_any_tags() + public function test_it_can_find_media_matching_any_tags(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -114,7 +114,7 @@ public function test_it_can_find_media_matching_any_tags() ); } - public function test_it_can_find_media_matching_multiple_tags() + public function test_it_can_find_media_matching_multiple_tags(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -135,7 +135,7 @@ public function test_it_can_find_media_matching_multiple_tags() $this->assertEquals(0, $mediable->getMedia(['foo', 'bat'], true)->count()); } - public function test_it_can_check_presence_of_attached_media() + public function test_it_can_check_presence_of_attached_media(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -158,7 +158,7 @@ public function test_it_can_check_presence_of_attached_media() ); } - public function test_it_can_list_media_by_tag() + public function test_it_can_list_media_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -173,7 +173,7 @@ public function test_it_can_list_media_by_tag() $this->assertEquals([2], $result['bar']->pluck('id')->toArray()); } - public function test_it_can_detach_media_by_tag() + public function test_it_can_detach_media_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -183,7 +183,7 @@ public function test_it_can_detach_media_by_tag() $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_can_detach_media_of_multiple_tags() + public function test_it_can_detach_media_of_multiple_tags(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(['id' => 1]); @@ -196,7 +196,7 @@ public function test_it_can_detach_media_of_multiple_tags() $this->assertEquals(0, $mediable->getMedia('bar')->count()); } - public function test_it_can_sync_media_by_tag() + public function test_it_can_sync_media_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 2]); @@ -214,7 +214,7 @@ public function test_it_can_sync_media_by_tag() ); } - public function test_it_can_sync_media_to_multiple_tags() + public function test_it_can_sync_media_to_multiple_tags(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -231,7 +231,7 @@ public function test_it_can_sync_media_to_multiple_tags() $this->assertEquals([2, 3], $mediable->getMedia('baz')->pluck('id')->toArray()); } - public function test_it_can_be_queried_by_any_media() + public function test_it_can_be_queried_by_any_media(): void { $mediable = factory(SampleMediable::class)->create(); $mediable2 = factory(SampleMediable::class)->create(); @@ -242,7 +242,7 @@ public function test_it_can_be_queried_by_any_media() $this->assertEquals([$mediable->getKey()], $result->modelKeys()); } - public function test_it_can_be_queried_by_tag() + public function test_it_can_be_queried_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -256,7 +256,7 @@ public function test_it_can_be_queried_by_tag() ); } - public function test_it_can_be_queried_by_tag_matching_all() + public function test_it_can_be_queried_by_tag_matching_all(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -279,7 +279,7 @@ public function test_it_can_be_queried_by_tag_matching_all() ); } - public function test_it_can_list_the_tags_a_media_is_attached_to() + public function test_it_can_list_the_tags_a_media_is_attached_to(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -291,18 +291,18 @@ public function test_it_can_list_the_tags_a_media_is_attached_to() $this->assertContains('bar', $mediable->getTagsForMedia($media)); } - public function test_it_can_disable_automatic_rehydration() + public function test_it_can_disable_automatic_rehydration(): void { $mediable = factory(SampleMediable::class)->create(); $mediable->rehydrates_media = false; $media = factory(Media::class)->create(); - $mediable->media; + $mediable->media = new MediableCollection(); $mediable->attachMedia($media, 'foo'); $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_can_eager_load_media() + public function test_it_can_eager_load_media(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -314,7 +314,7 @@ public function test_it_can_eager_load_media() $this->assertFalse($result->media[0]->relationLoaded('variants')); } - public function test_it_can_eager_load_media_by_tag() + public function test_it_can_eager_load_media_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -330,7 +330,7 @@ public function test_it_can_eager_load_media_by_tag() $this->assertFalse($result->media[0]->relationLoaded('variants')); } - public function test_it_can_eager_load_media_by_tag_matching_all() + public function test_it_can_eager_load_media_by_tag_matching_all(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -351,7 +351,7 @@ public function test_it_can_eager_load_media_by_tag_matching_all() $this->assertFalse($result->media[0]->relationLoaded('variants')); } - public function test_it_can_eager_load_media_with_variants() + public function test_it_can_eager_load_media_with_variants(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -368,7 +368,7 @@ public function test_it_can_eager_load_media_with_variants() $this->assertTrue($result->media[0]->relationLoaded('variants')); } - public function test_it_can_eager_load_media_with_variants_by_tag() + public function test_it_can_eager_load_media_with_variants_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -389,7 +389,7 @@ public function test_it_can_eager_load_media_with_variants_by_tag() $this->assertTrue($result->media[0]->relationLoaded('variants')); } - public function test_it_can_eager_load_media_with_variants_by_tag_matching_all() + public function test_it_can_eager_load_media_with_variants_by_tag_matching_all(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -422,7 +422,7 @@ public function test_it_can_eager_load_media_with_variants_by_tag_matching_all() $this->assertTrue($result->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media() + public function test_it_can_lazy_eager_load_media(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -435,7 +435,7 @@ public function test_it_can_lazy_eager_load_media() $this->assertFalse($result->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_by_tag() + public function test_it_can_lazy_eager_load_media_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -451,7 +451,7 @@ public function test_it_can_lazy_eager_load_media_by_tag() $this->assertFalse($result->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_by_tag_matching_all() + public function test_it_can_lazy_eager_load_media_by_tag_matching_all(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -474,7 +474,7 @@ public function test_it_can_lazy_eager_load_media_by_tag_matching_all() $this->assertFalse($result->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_with_variants() + public function test_it_can_lazy_eager_load_media_with_variants(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -493,7 +493,7 @@ public function test_it_can_lazy_eager_load_media_with_variants() $this->assertTrue($result->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_with_variants_by_tag() + public function test_it_can_lazy_eager_load_media_with_variants_by_tag(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -516,8 +516,8 @@ public function test_it_can_lazy_eager_load_media_with_variants_by_tag() $this->assertTrue($result->media[0]->relationLoaded('variants')); } - public function test_it_can_lazy_eager_load_media_with_variants_by_tag_matching_all() - { + public function test_it_can_lazy_eager_load_media_with_variants_by_tag_matching_all( + ): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); $media2 = factory(Media::class)->create(['id' => 2]); @@ -546,20 +546,23 @@ public function test_it_can_lazy_eager_load_media_with_variants_by_tag_matching_ $this->assertTrue($result->media[0]->relationLoaded('variants')); $result = SampleMediable::first(); - $this->assertSame($result, $result->loadMediaWithVariantsMatchAll(['bar', 'foo'])); + $this->assertSame( + $result, + $result->loadMediaWithVariantsMatchAll(['bar', 'foo']) + ); $this->assertTrue($result->relationLoaded('media')); $this->assertEquals([2, 2], $result->media->pluck('id')->toArray()); $this->assertTrue($result->media[0]->relationLoaded('originalMedia')); $this->assertTrue($result->media[0]->relationLoaded('variants')); } - public function test_it_uses_custom_collection() + public function test_it_uses_custom_collection(): void { $mediable = factory(SampleMediable::class)->make(); $this->assertInstanceOf(MediableCollection::class, $mediable->newCollection([])); } - public function test_it_cascades_relationship_on_delete() + public function test_it_cascades_relationship_on_delete(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(); @@ -569,7 +572,7 @@ public function test_it_cascades_relationship_on_delete() $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_doesnt_cascade_relationship_on_soft_delete() + public function test_it_doesnt_cascade_relationship_on_soft_delete(): void { $mediable = factory(SampleMediableSoftDelete::class)->create(); $media = factory(Media::class)->create(); @@ -579,7 +582,7 @@ public function test_it_doesnt_cascade_relationship_on_soft_delete() $this->assertEquals(1, $mediable->getMedia('foo')->count()); } - public function test_it_cascades_relationships_on_soft_delete_with_config() + public function test_it_cascades_relationships_on_soft_delete_with_config(): void { $mediable = factory(SampleMediableSoftDelete::class)->create(); $media = factory(Media::class)->create(); @@ -591,7 +594,7 @@ public function test_it_cascades_relationships_on_soft_delete_with_config() $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_cascades_relationship_on_force_delete() + public function test_it_cascades_relationship_on_force_delete(): void { $mediable = factory(SampleMediableSoftDelete::class)->create(); $media = factory(Media::class)->create(); @@ -601,7 +604,7 @@ public function test_it_cascades_relationship_on_force_delete() $this->assertEquals(0, $mediable->getMedia('foo')->count()); } - public function test_it_reads_highest_order() + public function test_it_reads_highest_order(): void { $mediable = factory(SampleMediable::class)->create(); $media = factory(Media::class)->create(['id' => 1]); @@ -612,7 +615,7 @@ public function test_it_reads_highest_order() $this->assertEquals(['foo' => 1], $method->invoke($mediable, 'foo')); } - public function test_it_increments_order() + public function test_it_increments_order(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -637,7 +640,7 @@ public function test_it_increments_order() ); } - public function test_it_increments_order_when_attaching_multiple() + public function test_it_increments_order_when_attaching_multiple(): void { $mediable = factory(SampleMediable::class)->create(); $media1 = factory(Media::class)->create(['id' => 1]); @@ -652,7 +655,7 @@ public function test_it_increments_order_when_attaching_multiple() ); } - public function test_it_can_unset_order() + public function test_it_can_unset_order(): void { $mediable = factory(SampleMediable::class)->make(); @@ -661,7 +664,7 @@ public function test_it_can_unset_order() $this->assertEquals(0, preg_match('/order by `order`/i', $query)); } - public function test_it_can_create_mediables_on_custom_table() + public function test_it_can_create_mediables_on_custom_table(): void { config()->set('mediable.mediables_table', 'prefixed_mediables'); diff --git a/tests/Integration/SourceAdapters/SourceAdapterFactoryTest.php b/tests/Integration/SourceAdapters/SourceAdapterFactoryTest.php index 722f8f09..faa934fe 100644 --- a/tests/Integration/SourceAdapters/SourceAdapterFactoryTest.php +++ b/tests/Integration/SourceAdapters/SourceAdapterFactoryTest.php @@ -10,7 +10,7 @@ class SourceAdapterFactoryTest extends TestCase { - public function test_it_allows_setting_adapter_for_class() + public function test_it_allows_setting_adapter_for_class(): void { $factory = new SourceAdapterFactory; $source = $this->createMock(stdClass::class); @@ -21,7 +21,7 @@ public function test_it_allows_setting_adapter_for_class() $this->assertInstanceOf($adapterClass, $factory->create($source)); } - public function test_it_allows_setting_adapter_for_pattern() + public function test_it_allows_setting_adapter_for_pattern(): void { $factory = new SourceAdapterFactory; $adapterClass = get_class($this->createMock(SourceAdapterInterface::class)); @@ -30,35 +30,35 @@ public function test_it_allows_setting_adapter_for_pattern() $this->assertInstanceOf($adapterClass, $factory->create('b1')); } - public function test_it_throws_exception_if_invalid_adapter_for_class() + public function test_it_throws_exception_if_invalid_adapter_for_class(): void { $factory = new SourceAdapterFactory; $this->expectException(ConfigurationException::class); $factory->setAdapterForClass(stdClass::class, stdClass::class); } - public function test_it_throws_exception_if_invalid_adapter_for_pattern() + public function test_it_throws_exception_if_invalid_adapter_for_pattern(): void { $factory = new SourceAdapterFactory; $this->expectException(ConfigurationException::class); $factory->setAdapterForPattern(stdClass::class, 'foo'); } - public function test_it_throws_exception_if_no_match_for_class() + public function test_it_throws_exception_if_no_match_for_class(): void { $factory = new SourceAdapterFactory; $this->expectException(ConfigurationException::class); $factory->create(new stdClass); } - public function test_it_throws_exception_if_no_match_for_pattern() + public function test_it_throws_exception_if_no_match_for_pattern(): void { $factory = new SourceAdapterFactory; $this->expectException(ConfigurationException::class); $factory->create('foo'); } - public function test_it_returns_adapters_unmodified() + public function test_it_returns_adapters_unmodified(): void { $factory = new SourceAdapterFactory; $adapter = $this->createMock(SourceAdapterInterface::class); @@ -66,7 +66,7 @@ public function test_it_returns_adapters_unmodified() $this->assertEquals($adapter, $factory->create($adapter)); } - public function test_it_is_accessible_via_the_container() + public function test_it_is_accessible_via_the_container(): void { $this->assertInstanceOf( SourceAdapterFactory::class, diff --git a/tests/Integration/SourceAdapters/SourceAdapterTest.php b/tests/Integration/SourceAdapters/SourceAdapterTest.php index d9eb4787..a1a676ab 100644 --- a/tests/Integration/SourceAdapters/SourceAdapterTest.php +++ b/tests/Integration/SourceAdapters/SourceAdapterTest.php @@ -2,6 +2,9 @@ namespace Plank\Mediable\Tests\Integration\SourceAdapters; +use GuzzleHttp\Psr7\Utils; +use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException; +use Plank\Mediable\SourceAdapters\DataUrlAdapter; use Plank\Mediable\SourceAdapters\FileAdapter; use Plank\Mediable\SourceAdapters\LocalPathAdapter; use Plank\Mediable\SourceAdapters\RawContentAdapter; @@ -10,31 +13,19 @@ use Plank\Mediable\SourceAdapters\StreamAdapter; use Plank\Mediable\SourceAdapters\StreamResourceAdapter; use Plank\Mediable\SourceAdapters\UploadedFileAdapter; -use Plank\Mediable\Stream; use Plank\Mediable\Tests\TestCase; +use Psr\Http\Message\StreamInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; class SourceAdapterTest extends TestCase { - private const READABLE_MODES = [ - 'r' => true, - 'w+' => true, - 'r+' => true, - 'x+' => true, - 'c+' => true, - 'rb' => true, - 'w+b' => true, - 'r+b' => true, - 'x+b' => true, - 'c+b' => true, - 'rt' => true, - 'w+t' => true, - 'r+t' => true, - 'x+t' => true, - 'c+t' => true, - 'a+' => true - ]; + private const EXPECTED_FILENAME = 'plank'; + private const EXPECTED_EXTENSION = 'png'; + private const EXPECTED_MIME = 'image/png'; + private const EXPECTED_SIZE = 7173; + private const EXPECTED_HASH_MD5 = '3ef5e70366086147c2695325d79a25cc'; + private const EXPECTED_HASH_SHA1 = '5e96e1fa58067853219c4cb6d3c1ce01cc5cc8ce'; public function setUp(): void { @@ -47,11 +38,13 @@ protected function getEnvironmentSetUp($app) $app['filesystem']->disk('uploads')->put('plank.png', $this->sampleFile()); } - public function adapterProvider() + public static function adapterProvider(): array { - $file = $this->sampleFilePath(); + $file = TestCase::sampleFilePath(); $string = file_get_contents($file); - $url = $this->remoteFilePath() . '?foo=bar.baz'; + $base64DataUrl = 'data:image/png;base64,' . base64_encode($string); + $rawDataUrl = 'data:image/png,' . rawurlencode($string); + $url = TestCase::remoteFilePath() . '?foo=bar.baz'; $uploadedFile = new UploadedFile( $file, @@ -62,72 +55,179 @@ public function adapterProvider() ); $fileResource = fopen($file, 'rb'); - $fileStream = new Stream(fopen($file, 'rb')); + $fileStream = Utils::streamFor(fopen($file, 'rb')); $httpResource = fopen($url, 'rb'); - $httpStream = new Stream(fopen($url, 'rb')); + $httpStream = Utils::streamFor(fopen($url, 'rb')); $memoryResource = fopen('php://memory', 'w+b'); fwrite($memoryResource, $string); rewind($memoryResource); - $memoryStream = new Stream(fopen('php://memory', 'w+b')); + $memoryStream = Utils::streamFor(fopen('php://memory', 'w+b')); $memoryStream->write($string); + $dataStream = Utils::streamFor(fopen('data://image/png,' . rawurlencode($string), 'rb')); $data = [ - 'FileAdapter' => [FileAdapter::class, new File($file), $file, 'plank'], + 'FileAdapter' => [ + 'adapterClass' => FileAdapter::class, + 'source' => new File($file), + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => null, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, + ], 'UploadedFileAdapter' => [ - UploadedFileAdapter::class, - $uploadedFile, - $file, - 'plank' + 'adapterClass' => UploadedFileAdapter::class, + 'source' => $uploadedFile, + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => self::EXPECTED_MIME, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, + ], + 'LocalPathAdapter' => [ + 'adapterClass' => LocalPathAdapter::class, + 'source' => $file, + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => null, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, + ], + 'RemoteUrlAdapter' => [ + 'adapterClass' => RemoteUrlAdapter::class, + 'source' => $url, + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => self::EXPECTED_MIME, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, + ], + 'RawContentAdapter' => [ + 'adapterClass' => RawContentAdapter::class, + 'source' => $string, + 'filename' => null, + 'extension' => null, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => null, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, + ], + 'DataUrlAdapter_base64' => [ + 'adapterClass' => DataUrlAdapter::class, + 'source' => $base64DataUrl, + 'filename' => null, + 'extension' => null, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => self::EXPECTED_MIME, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, + ], + 'DataUrlAdapter_urlencode' => [ + 'adapterClass' => DataUrlAdapter::class, + 'source' => $rawDataUrl, + 'filename' => null, + 'extension' => null, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => self::EXPECTED_MIME, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, ], - 'LocalPathAdapter' => [LocalPathAdapter::class, $file, $file, 'plank'], - 'RemoteUrlAdapter' => [RemoteUrlAdapter::class, $url, $url, 'plank'], - 'RawContentAdapter' => [RawContentAdapter::class, $string, null, '', false], 'StreamResourceAdapter_Local' => [ - StreamResourceAdapter::class, - $fileResource, - $file, - 'plank' + 'adapterClass' => StreamResourceAdapter::class, + 'source' => $fileResource, + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => null, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, ], 'StreamAdapter_Local' => [ - StreamAdapter::class, - $fileStream, - $file, - 'plank', - false + 'adapterClass' => StreamAdapter::class, + 'source' => $fileStream, + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => null, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, ], 'StreamResourceAdapter_Remote' => [ - StreamResourceAdapter::class, - $httpResource, - $url, - 'plank' + 'adapterClass' => StreamResourceAdapter::class, + 'source' => $httpResource, + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => self::EXPECTED_MIME, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, ], 'StreamAdapter_Remote' => [ - StreamAdapter::class, - $httpStream, - $url, - 'plank', - false + 'adapterClass' => StreamAdapter::class, + 'source' => $httpStream, + 'filename' => self::EXPECTED_FILENAME, + 'extension' => self::EXPECTED_EXTENSION, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => self::EXPECTED_MIME, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, ], 'StreamResourceAdapter_Memory' => [ - StreamResourceAdapter::class, - $memoryResource, - 'php://memory', - '' + 'adapterClass' => StreamResourceAdapter::class, + 'source' => $memoryResource, + 'filename' => null, + 'extension' => null, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => null, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, ], 'StreamAdapter_Memory' => [ - StreamAdapter::class, - $memoryStream, - 'php://memory', - '' + 'adapterClass' => StreamAdapter::class, + 'source' => $memoryStream, + 'filename' => null, + 'extension' => null, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => null, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, + ], + 'StreamAdapter_Data' => [ + 'adapterClass' => StreamAdapter::class, + 'source' => $dataStream, + 'filename' => null, + 'extension' => null, + 'inferredMime' => self::EXPECTED_MIME, + 'clientMime' => self::EXPECTED_MIME, + 'size' => self::EXPECTED_SIZE, + 'hash_md5' => self::EXPECTED_HASH_MD5, + 'hash_sha1' => self::EXPECTED_HASH_SHA1, ], ]; return $data; } - public function invalidAdapterProvider() + public static function invalidAdapterProvider(): array { $file = __DIR__ . '/../../_data/invalid.png'; $url = 'https://raw.githubusercontent.com/plank/laravel-mediable/master/tests/_data/invalid.png'; @@ -141,121 +241,53 @@ public function invalidAdapterProvider() ); return [ - [new FileAdapter(new File($file, false))], - [new LocalPathAdapter($file)], - [new RemoteUrlAdapter($url)], - [new RemoteUrlAdapter('http://example.invalid')], - [new UploadedFileAdapter($uploadedFile)], - [new StreamResourceAdapter(fopen($this->sampleFilePath(), 'a'))], - [new StreamResourceAdapter(fopen('php://stdin', 'w'))], + [FileAdapter::class, new File($file, false)], + [LocalPathAdapter::class, $file], + [RemoteUrlAdapter::class, $url], + [RemoteUrlAdapter::class, 'http://example.invalid'], + [UploadedFileAdapter::class, $uploadedFile], + [StreamResourceAdapter::class, fopen(TestCase::sampleFilePath(), 'a')], + [StreamResourceAdapter::class, fopen('php://stdin', 'w')], ]; } /** * @dataProvider adapterProvider */ - public function test_it_can_return_source($adapterClass, $source) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); - $this->assertEquals($source, $adapter->getSource()); - } - - /** - * @dataProvider adapterProvider - */ - public function test_it_adapts_absolute_path($adapterClass, $source, $path) - { + public function test_it_extracts_expected_information_from_source( + string $adapterClass, + mixed $source, + ?string $filename, + ?string $extension, + string $inferredMime, + ?string $clientMime, + int $size, + string $md5Hash, + string $sha1Hash + ) { /** @var SourceAdapterInterface $adapter */ $adapter = new $adapterClass($source); - $this->assertEquals($path, $adapter->path()); - } - /** - * @dataProvider adapterProvider - */ - public function test_it_adapts_filename($adapterClass, $source, $path, $filename) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); + $stream = $adapter->getStream(); + $this->assertInstanceOf(StreamInterface::class, $stream); + $this->assertTrue($stream->isReadable()); $this->assertSame($filename, $adapter->filename()); - } - - /** - * @dataProvider adapterProvider - */ - public function test_it_adapts_extension($adapterClass, $source) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); - $this->assertEquals('png', $adapter->extension()); - } - - /** - * @dataProvider adapterProvider - */ - public function test_it_adapts_mime_type($adapterClass, $source) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); - $this->assertEquals('image/png', $adapter->mimeType()); - } - - /** - * @dataProvider adapterProvider - */ - public function test_it_adapts_file_contents($adapterClass, $source) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); - $this->assertIsString($adapter->contents()); - } - - /** - * @dataProvider adapterProvider - */ - public function test_it_adapts_to_stream($adapterClass, $source) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); - $stream = $adapter->getStreamResource(); - try { - $this->assertTrue(is_resource($stream)); - $metadata = stream_get_meta_data($stream); - $this->assertArrayHasKey($metadata['mode'], self::READABLE_MODES); - } finally { - if (is_resource($stream)) { - fclose($stream); - } - } - } - - /** - * @dataProvider adapterProvider - */ - public function test_it_adapts_file_size($adapterClass, $source) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); - $this->assertEquals(7173, $adapter->size()); - } - - /** - * @dataProvider adapterProvider - */ - public function test_it_verifies_file_validity($adapterClass, $source) - { - /** @var SourceAdapterInterface $adapter */ - $adapter = new $adapterClass($source); - $this->assertTrue($adapter->valid()); + $this->assertSame($extension, $adapter->extension()); + $this->assertSame($inferredMime, $adapter->mimeType()); + $this->assertSame($clientMime, $adapter->clientMimeType()); + $this->assertSame($size, $adapter->size()); + $this->assertSame($md5Hash, $adapter->hash()); + $this->assertSame($sha1Hash, $adapter->hash('sha1')); } /** * @dataProvider invalidAdapterProvider */ public function test_it_verifies_file_validity_failure( - SourceAdapterInterface $adapter + string $adapterClass, + $args ) { - $this->assertFalse($adapter->valid()); + $this->expectException(ConfigurationException::class); + new $adapterClass($args); } } diff --git a/tests/Integration/UrlGenerators/LocalUrlGeneratorTest.php b/tests/Integration/UrlGenerators/LocalUrlGeneratorTest.php index d0cda04e..83ff1518 100644 --- a/tests/Integration/UrlGenerators/LocalUrlGeneratorTest.php +++ b/tests/Integration/UrlGenerators/LocalUrlGeneratorTest.php @@ -10,7 +10,7 @@ class LocalUrlGeneratorTest extends TestCase { - public function test_it_generates_absolute_path() + public function test_it_generates_absolute_path(): void { $generator = $this->setupGenerator(); $this->assertEquals( @@ -19,25 +19,25 @@ public function test_it_generates_absolute_path() ); } - public function test_it_generates_url() + public function test_it_generates_url(): void { $generator = $this->setupGenerator(); $this->assertEquals('http://localhost/uploads/foo/bar.jpg', $generator->getUrl()); } - public function test_it_attempts_to_generate_url_for_non_public_disk() + public function test_it_attempts_to_generate_url_for_non_public_disk(): void { $generator = $this->setupGenerator('tmp'); $this->assertEquals('/storage/foo/bar.jpg', $generator->getUrl()); } - public function test_it_accepts_public_visibility() + public function test_it_accepts_public_visibility(): void { $generator = $this->setupGenerator('public_storage'); $this->assertEquals('http://localhost/storage/foo/bar.jpg', $generator->getUrl()); } - public function public_visibility_provider() + public static function public_visibility_provider(): array { return [ ['uploads', true, true], @@ -56,12 +56,12 @@ public function test_it_checks_public_visibility( string $disk, bool $public, bool $expectedAccessibility - ) { + ): void { $generator = $this->setupGenerator($disk, $public); $this->assertSame($expectedAccessibility, $generator->isPubliclyAccessible()); } - public function test_it_checks_public_visibility_mock_disk() + public function test_it_checks_public_visibility_mock_disk(): void { $filesystem = $this->createConfiguredMock( FilesystemManager::class, diff --git a/tests/Integration/UrlGenerators/S3UrlGeneratorTest.php b/tests/Integration/UrlGenerators/S3UrlGeneratorTest.php index 02c1d8bf..00c715d2 100644 --- a/tests/Integration/UrlGenerators/S3UrlGeneratorTest.php +++ b/tests/Integration/UrlGenerators/S3UrlGeneratorTest.php @@ -8,8 +8,6 @@ use Plank\Mediable\Tests\TestCase; use Plank\Mediable\UrlGenerators\S3UrlGenerator; -use function GuzzleHttp\Psr7\parse_query; - class S3UrlGeneratorTest extends TestCase { public function setUp(): void @@ -29,7 +27,7 @@ public function tearDown(): void parent::tearDown(); } - public function test_it_generates_absolute_path() + public function test_it_generates_absolute_path(): void { $generator = $this->setupGenerator(); $this->assertEquals( @@ -43,7 +41,7 @@ public function test_it_generates_absolute_path() ); } - public function test_it_generates_url() + public function test_it_generates_url(): void { $generator = $this->setupGenerator(); $this->assertEquals( @@ -57,7 +55,7 @@ public function test_it_generates_url() ); } - public function test_it_generates_temporary_url() + public function test_it_generates_temporary_url(): void { $generator = $this->setupGenerator(); $url = $generator->getTemporaryUrl(Carbon::now()->addDay()); @@ -90,7 +88,7 @@ public function test_it_generates_temporary_url() ); } - protected function setupGenerator() + protected function setupGenerator(): S3UrlGenerator { $media = $this->getMedia(); $this->useFilesystem('s3'); diff --git a/tests/Integration/UrlGenerators/UrlGeneratorFactoryTest.php b/tests/Integration/UrlGenerators/UrlGeneratorFactoryTest.php index db35f6b1..a1dfd39b 100644 --- a/tests/Integration/UrlGenerators/UrlGeneratorFactoryTest.php +++ b/tests/Integration/UrlGenerators/UrlGeneratorFactoryTest.php @@ -13,7 +13,7 @@ class UrlGeneratorFactoryTest extends TestCase { - public function test_it_sets_generator_for_driver() + public function test_it_sets_generator_for_driver(): void { $factory = new UrlGeneratorFactory; $generator = $this->createMock(UrlGeneratorInterface::class); @@ -26,7 +26,7 @@ public function test_it_sets_generator_for_driver() $this->assertInstanceOf($class, $result); } - public function test_it_throws_exception_for_invalid_generator() + public function test_it_throws_exception_for_invalid_generator(): void { $factory = new UrlGeneratorFactory; $class = get_class($this->createMock(stdClass::class)); @@ -34,7 +34,7 @@ public function test_it_throws_exception_for_invalid_generator() $factory->setGeneratorForFilesystemDriver($class, 'foo'); } - public function test_it_throws_exception_if_cant_map_to_driver() + public function test_it_throws_exception_if_cant_map_to_driver(): void { $factory = new UrlGeneratorFactory; $media = factory(Media::class)->make(); @@ -42,7 +42,7 @@ public function test_it_throws_exception_if_cant_map_to_driver() $factory->create($media); } - public function test_it_follows_scoped_prefix() + public function test_it_follows_scoped_prefix(): void { if (version_compare($this->app->version(), '9.30.0', '<')) { $this->markTestSkipped("scoped disk prefixes are only supported in laravel 9.30.0+"); diff --git a/tests/Mocks/SampleExceptionHandler.php b/tests/Mocks/SampleExceptionHandler.php index e4317684..507a532a 100644 --- a/tests/Mocks/SampleExceptionHandler.php +++ b/tests/Mocks/SampleExceptionHandler.php @@ -16,9 +16,9 @@ class SampleExceptionHandler * we would pass it to the parent::render(). * * @param \Exception $e - * @return \Symfony\Component\HttpKernel\Exception\HttpException|\Exception + * @return \Symfony\Component\HttpKernel\Exception\HttpException|\Throwable */ - public function render(\Exception $e) + public function render(\Throwable $e): \Throwable { return $this->transformMediaUploadException($e); } diff --git a/tests/Mocks/SampleMediable.php b/tests/Mocks/SampleMediable.php index 6290731d..5d460312 100644 --- a/tests/Mocks/SampleMediable.php +++ b/tests/Mocks/SampleMediable.php @@ -3,12 +3,13 @@ namespace Plank\Mediable\Tests\Mocks; use Illuminate\Database\Eloquent\Model; +use Plank\Mediable\MediableInterface; use Plank\Mediable\Mediable; /** * @method static self first() */ -class SampleMediable extends Model +class SampleMediable extends Model implements MediableInterface { use Mediable; diff --git a/tests/Mocks/SampleMediableSoftDelete.php b/tests/Mocks/SampleMediableSoftDelete.php index cbee72f4..2386adb3 100644 --- a/tests/Mocks/SampleMediableSoftDelete.php +++ b/tests/Mocks/SampleMediableSoftDelete.php @@ -4,9 +4,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Plank\Mediable\MediableInterface; use Plank\Mediable\Mediable; -class SampleMediableSoftDelete extends Model +class SampleMediableSoftDelete extends Model implements MediableInterface { use Mediable; use SoftDeletes; diff --git a/tests/TestCase.php b/tests/TestCase.php index c6977576..9226c4b2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,14 +4,18 @@ use Dotenv\Dotenv; use Faker\Factory; -use Illuminate\Contracts\Console\Kernel; -use Illuminate\Database\Eloquent\Model; +use GuzzleHttp\Psr7\Utils; use Illuminate\Filesystem\Filesystem; +use Intervention\Image\Drivers\Gd\Driver; +use Intervention\Image\ImageManager; use Orchestra\Testbench\TestCase as BaseTestCase; +use PHPUnit\Framework\MockObject\MockObject; use Plank\Mediable\Media; use Plank\Mediable\MediableServiceProvider; use Plank\Mediable\Tests\Mocks\MockCallable; use ReflectionClass; +use PHPUnit\Framework\Constraint\Callback; +use PHPUnit\Framework\Constraint\Constraint; class TestCase extends BaseTestCase { @@ -41,7 +45,7 @@ protected function getPackageAliases($app) protected function getEnvironmentSetUp($app) { if (file_exists(dirname(__DIR__) . '/.env')) { - Dotenv::create(dirname(__DIR__))->load(); + Dotenv::createImmutable(dirname(__DIR__))->load(); } //use in-memory database $app['config']->set('database.connections.testing', [ @@ -58,6 +62,10 @@ protected function getEnvironmentSetUp($app) 'root' => storage_path('tmp'), 'visibility' => 'private' ], + 'novisibility' => [ + 'driver' => 'local', + 'root' => storage_path('tmp'), + ], //public local storage 'uploads' => [ 'driver' => 'local', @@ -91,11 +99,18 @@ protected function getEnvironmentSetUp($app) $app['config']->set('mediable.allowed_disks', [ 'tmp', + 'novisibility', 'uploads' ]); + + $app['config']->set('mediable.image_optimization.enabled', false); + + if (class_exists(Driver::class)) { + $app->instance(ImageManager::class, new ImageManager(new Driver())); + } } - protected function getPrivateProperty($class, $property_name) + protected function getPrivateProperty($class, $property_name): \ReflectionProperty { $reflector = new ReflectionClass($class); $property = $reflector->getProperty($property_name); @@ -103,7 +118,7 @@ protected function getPrivateProperty($class, $property_name) return $property; } - protected function getPrivateMethod($class, $method_name) + protected function getPrivateMethod($class, $method_name): \ReflectionMethod { $reflector = new ReflectionClass($class); $method = $reflector->getMethod($method_name); @@ -111,7 +126,7 @@ protected function getPrivateMethod($class, $method_name) return $method; } - protected function seedFileForMedia(Media $media, $contents = '') + protected function seedFileForMedia(Media $media, $contents = ''): void { app('filesystem')->disk($media->disk)->put( $media->getDiskPath(), @@ -120,12 +135,12 @@ protected function seedFileForMedia(Media $media, $contents = '') ); } - protected function s3ConfigLoaded() + protected function s3ConfigLoaded(): bool { return env('S3_KEY') && env('S3_SECRET') && env('S3_REGION') && env('S3_BUCKET'); } - protected function useDatabase() + protected function useDatabase(): void { $this->app->useDatabasePath(dirname(__DIR__)); $this->loadMigrationsFrom( @@ -138,7 +153,7 @@ protected function useDatabase() ); } - protected function useFilesystem($disk) + protected function useFilesystem($disk): void { if (!$this->app['config']->has('filesystems.disks.' . $disk)) { return; @@ -148,7 +163,7 @@ protected function useFilesystem($disk) $filesystem->cleanDirectory($root); } - protected function useFilesystems() + protected function useFilesystems(): void { $disks = $this->app['config']->get('filesystems.disks'); foreach ($disks as $disk) { @@ -156,24 +171,24 @@ protected function useFilesystems() } } - protected function sampleFilePath() + protected static function sampleFilePath(): string { return realpath(__DIR__ . '/_data/plank.png'); } - protected function alternateFilePath() + protected static function alternateFilePath(): string { return realpath(__DIR__ . '/_data/plank2.png'); } - protected function remoteFilePath() + protected static function remoteFilePath(): string { return 'https://raw.githubusercontent.com/plank/laravel-mediable/master/tests/_data/plank.png'; } protected function sampleFile() { - return fopen($this->sampleFilePath(), 'r'); + return Utils::tryFopen(TestCase::sampleFilePath(), 'r'); } protected function makeMedia(array $attributes = []): Media @@ -186,8 +201,53 @@ protected function createMedia(array $attributes = []): Media return factory(Media::class)->create($attributes); } - protected function getMockCallable() + /** + * @return callable&MockObject + */ + protected function getMockCallable(): callable { return $this->createPartialMock(MockCallable::class, ['__invoke']); } + + /** + * @param array $firstCallArguments + * @param array ...$consecutiveCallsArguments + * + * @return \Generator> + */ + public static function withConsecutive(array $firstCallArguments, array ...$consecutiveCallsArguments): \Generator + { + foreach ($consecutiveCallsArguments as $consecutiveCallArguments) { + self::assertSameSize($firstCallArguments, $consecutiveCallArguments, 'Each expected arguments list need to have the same size.'); + } + + $allConsecutiveCallsArguments = [$firstCallArguments, ...$consecutiveCallsArguments]; + + $numberOfArguments = count($firstCallArguments); + $argumentList = []; + for ($argumentPosition = 0; $argumentPosition < $numberOfArguments; $argumentPosition++) { + $argumentList[$argumentPosition] = array_column($allConsecutiveCallsArguments, $argumentPosition); + } + + $mockedMethodCall = 0; + $callbackCall = 0; + foreach ($argumentList as $index => $argument) { + yield new Callback( + static function (mixed $actualArgument) use ($argumentList, &$mockedMethodCall, &$callbackCall, $index, $numberOfArguments): bool { + $expected = $argumentList[$index][$mockedMethodCall] ?? null; + + $callbackCall++; + $mockedMethodCall = (int) ($callbackCall / $numberOfArguments); + + if ($expected instanceof Constraint) { + self::assertThat($actualArgument, $expected); + } else { + self::assertEquals($expected, $actualArgument); + } + + return true; + }, + ); + } + } }