├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── automated-test.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── UPGRADING.md ├── composer.json ├── config └── mediable.php ├── docs ├── Makefile ├── requirements.txt └── source │ ├── commands.rst │ ├── conf.py │ ├── configuration.rst │ ├── index.rst │ ├── installation.rst │ ├── media.rst │ ├── mediable.rst │ ├── types.rst │ ├── uploader.rst │ └── variants.rst ├── infection.json.dist ├── migrations ├── 2016_06_27_000000_create_mediable_tables.php ├── 2020_10_12_000000_add_variants_to_media.php └── 2024_03_30_000000_add_alt_to_media.php ├── phpstan.neon ├── phpunit.xml ├── src ├── Commands │ ├── ImportMediaCommand.php │ ├── PruneMediaCommand.php │ └── SyncMediaCommand.php ├── Exceptions │ ├── ImageManipulationException.php │ ├── MediaMoveException.php │ ├── MediaUpload │ │ ├── ConfigurationException.php │ │ ├── FileExistsException.php │ │ ├── FileNotFoundException.php │ │ ├── FileNotSupportedException.php │ │ ├── FileSizeException.php │ │ ├── ForbiddenException.php │ │ └── InvalidHashException.php │ ├── MediaUploadException.php │ └── MediaUrlException.php ├── Facades │ ├── ImageManipulator.php │ └── MediaUploader.php ├── HandlesMediaUploadExceptions.php ├── Helpers │ └── File.php ├── ImageManipulation.php ├── ImageManipulator.php ├── ImageOptimizer.php ├── Jobs │ └── CreateImageVariants.php ├── Media.php ├── MediaMover.php ├── MediaUploader.php ├── Mediable.php ├── MediableCollection.php ├── MediableInterface.php ├── MediableServiceProvider.php ├── SourceAdapters │ ├── DataUrlAdapter.php │ ├── FileAdapter.php │ ├── LocalPathAdapter.php │ ├── RawContentAdapter.php │ ├── RemoteUrlAdapter.php │ ├── SourceAdapterFactory.php │ ├── SourceAdapterInterface.php │ ├── StreamAdapter.php │ ├── StreamResourceAdapter.php │ └── UploadedFileAdapter.php └── UrlGenerators │ ├── BaseUrlGenerator.php │ ├── LocalUrlGenerator.php │ ├── S3UrlGenerator.php │ ├── TemporaryUrlGeneratorInterface.php │ ├── UrlGeneratorFactory.php │ └── UrlGeneratorInterface.php └── tests ├── Factories └── ModelFactory.php ├── Integration ├── Commands │ ├── ImportMediaCommandTest.php │ ├── PruneMediaCommandTest.php │ └── SyncMediaCommandTest.php ├── ConnectionTest.php ├── HandlesMediaExceptionsTest.php ├── Helpers │ └── FileTest.php ├── ImageManipulationTest.php ├── ImageManipulatorTest.php ├── ImageOptimizerTest.php ├── Jobs │ └── CreateImageVariantsTest.php ├── MediaTest.php ├── MediaUploaderTest.php ├── MediableCollectionTest.php ├── MediableTest.php ├── SourceAdapters │ ├── SourceAdapterFactoryTest.php │ └── SourceAdapterTest.php └── UrlGenerators │ ├── LocalUrlGeneratorTest.php │ ├── S3UrlGeneratorTest.php │ └── UrlGeneratorFactoryTest.php ├── Mocks ├── MediaSoftDelete.php ├── MediaSubclass.php ├── MockCallable.php ├── SampleExceptionHandler.php ├── SampleMediable.php └── SampleMediableSoftDelete.php ├── TestCase.php ├── _data ├── plank.png └── plank2.png └── migrations └── 2016_06_27_000001_create_mediable_test_tables.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [**.php] 10 | indent_style = space 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "composer" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: tuesday 9 | time: "12:00" 10 | labels: 11 | - "dependencies" 12 | open-pull-requests-limit: 10 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: weekly 18 | -------------------------------------------------------------------------------- /.github/workflows/automated-test.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit Tests 2 | on: [push] 3 | jobs: 4 | phpunit: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | php-versions: ['8.2', '8.3', '8.4'] 9 | prefer-lowest: ['','--prefer-lowest'] 10 | name: PHP ${{ matrix.php-versions }} ${{ matrix.prefer-lowest }} 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - uses: awalsh128/cache-apt-pkgs-action@latest 16 | with: 17 | packages: pngquant optipng 18 | version: 1.0 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php-versions }} 24 | extensions: gd 25 | coverage: pcov 26 | env: 27 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Get composer cache directory 30 | id: composer-cache 31 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 32 | 33 | - name: Cache dependencies 34 | uses: actions/cache@v4 35 | with: 36 | path: ${{ steps.composer-cache.outputs.dir }} 37 | key: ${{ runner.os }}-${{ matrix.php-version }}${{ matrix.prefer-lowest }}-composer-${{ hashFiles('**/composer.json') }} 38 | restore-keys: ${{ runner.os }}-${{ matrix.php-version }}${{ matrix.prefer-lowest }}-composer- 39 | 40 | - name: Install dependencies 41 | run: composer update --prefer-dist ${{ matrix.prefer-lowest }} 42 | env: 43 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Run phpunit 46 | run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml 47 | env: 48 | S3_KEY: ${{ secrets.S3_KEY }} 49 | S3_SECRET: ${{ secrets.S3_SECRET }} 50 | S3_REGION: ${{ secrets.S3_REGION }} 51 | S3_BUCKET: ${{ secrets.S3_BUCKET }} 52 | 53 | - name: Run PHPStan 54 | run: vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G 55 | 56 | - name: Upload coverage results to Coveralls 57 | env: 58 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | COVERALLS_PARALLEL: true 60 | COVERALLS_FLAG_NAME: PHP ${{ matrix.php-versions }} ${{ matrix.prefer-lowest }} 61 | run: vendor/bin/php-coveralls --coverage_clover=build/logs/clover.xml 62 | 63 | finish-coverage: 64 | needs: phpunit 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: Coveralls Finished 68 | uses: coverallsapp/github-action@master 69 | with: 70 | github-token: ${{ secrets.GITHUB_TOKEN }} 71 | parallel-finished: true 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | coverage/ 4 | .env 5 | .idea/ 6 | .phpunit.result.cache 7 | .phpunit.cache/ 8 | infection/ 9 | docs/build/ 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/source/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions to this project are always welcome. If you notice a bug or have an idea for a feature, please feel to send a pull request via [Github](https://github.com/plank/laravel-mediable). 4 | 5 | Please make sure to adhere to the following guidelines: 6 | 7 | - Please adhere to the [PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) 8 | - Write unit tests for any functionality you are adding 9 | - Any new features or changes in behaviour should be explained in the documentation. Don't forget to build the docs. 10 | - Send one pull request per feature and send each from their own feature branch. Don't send a pull request from your master branch. 11 | 12 | 13 | ## Tests 14 | 15 | The test suite can be run using phpunit 16 | 17 | ```bash 18 | $ phpunit 19 | ``` 20 | 21 | ## Documentation 22 | 23 | The documentation is written in [ReStructuredText](http://www.sphinx-doc.org/en/stable/rest.html), which needs to be built with [Sphinx](http://www.sphinx-doc.org/en/stable/index.html) before the changes will appear. 24 | 25 | To install Sphinx: 26 | ```bash 27 | $ pip install Sphinx 28 | ``` 29 | 30 | To build the docs: 31 | ```bash 32 | $ cd docs/ 33 | $ make html 34 | ``` 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Plank Multimedia Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel-Mediable 2 | 3 | [![Coveralls](https://img.shields.io/coveralls/plank/laravel-mediable.svg?style=flat-square)](https://coveralls.io/github/plank/laravel-mediable) 4 | [![StyleCI](https://styleci.io/repos/63791110/shield)](https://styleci.io/repos/63791110) 5 | [![Packagist](https://img.shields.io/packagist/v/plank/laravel-mediable.svg?style=flat-square)](https://packagist.org/packages/plank/laravel-mediable) 6 | 7 | Laravel-Mediable is a package for easily uploading and attaching media files to models with Laravel. 8 | 9 | ## Features 10 | 11 | - Filesystem-driven approach is easily configurable to allow any number of upload directories with different accessibility. Easily restrict uploads by MIME type, extension and/or aggregate type (e.g. `image` for JPEG, PNG or GIF). 12 | - Many-to-many polymorphic relationships allow any number of media to be assigned to any number of other models without any need to modify their schema. 13 | - Attach media to models with tags, in order to set and retrieve media for specific purposes, such as `'thumbnail'`, `'featured image'`, `'gallery'` or `'download'`. 14 | - Integrated support for integration/image for manipulating image files to create variants for different use cases. 15 | 16 | ## Example Usage 17 | 18 | Upload a file to the server, and place it in a directory on the filesystem disk named "uploads". This will create a Media record that can be used to refer to the file. 19 | 20 | ```php 21 | $media = MediaUploader::fromSource($request->file('thumb')) 22 | ->toDestination('uploads', 'blog/thumbnails') 23 | ->upload(); 24 | ``` 25 | 26 | Attach the Media to another eloquent model with one or more tags defining their relationship. 27 | 28 | ```php 29 | $post = Post::create($this->request->input()); 30 | $post->attachMedia($media, ['thumbnail']); 31 | ``` 32 | 33 | Retrieve the media from the model by its tag(s). 34 | 35 | ```php 36 | $post->getMedia('thumbnail')->first()->getUrl(); 37 | ``` 38 | 39 | ## Installation 40 | 41 | Add the package to your Laravel app using composer 42 | 43 | ```bash 44 | composer require plank/laravel-mediable 45 | ``` 46 | 47 | Register the package's service provider in `config/app.php`. In Laravel versions 5.5 and beyond, this step can be skipped if package auto-discovery is enabled. 48 | 49 | ```php 50 | 'providers' => [ 51 | ... 52 | Plank\Mediable\MediableServiceProvider::class, 53 | ... 54 | ]; 55 | ``` 56 | 57 | The package comes with a Facade for the image uploader, which you can optionally register as well. In Laravel versions 5.5 and beyond, this step can be skipped if package auto-discovery is enabled. 58 | 59 | ```php 60 | 'aliases' => [ 61 | ... 62 | 'MediaUploader' => Plank\Mediable\MediaUploaderFacade::class, 63 | ... 64 | ] 65 | ``` 66 | 67 | Publish the config file (`config/mediable.php`) of the package using artisan. 68 | 69 | ```bash 70 | php artisan vendor:publish --provider="Plank\Mediable\MediableServiceProvider" 71 | ``` 72 | 73 | Run the migrations to add the required tables to your database. 74 | 75 | ```bash 76 | php artisan migrate 77 | ``` 78 | 79 | ## Documentation 80 | 81 | Read the documentation [here](http://laravel-mediable.readthedocs.io/en/latest/). 82 | 83 | ## License 84 | 85 | This package is released under the MIT license (MIT). 86 | 87 | ## About Plank 88 | 89 | [Plank](http://plankdesign.com) is a web development agency based in Montreal, Canada. 90 | 91 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading 2 | 3 | ## 5.x to 6.x 4 | 5 | * Minimum PHP version moved to 8.1 6 | * Minimum Laravel version moved to 10 7 | * (optional) If upgrading the `intervention/image` package to 3.X, be sure to review the [upgrading instructions](https://image.intervention.io/v3/introduction/upgrade) for that package. Notably, the Laravel service providers for configuring the package have been moved to a separate repository [intervention/image-laravel](https://github.com/Intervention/image-laravel) 8 | * New database migration file is included with the package. Run `php artisan migrate` to apply the changes. 9 | * Add the `MediableInterface` to all models using the `Mediable` trait. 10 | * 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` 11 | ```php 12 | '^data:/?/?[^,]*,' => Plank\Mediable\SourceAdapters\DataUrlAdapter::class, 13 | ``` 14 | * 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`. 15 | ```php 16 | 'prefer_client_mime_type' => false, 17 | ``` 18 | * 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. 19 | * If you have implemented a custom SourceAdapter, you will need to apply the following changes from the `SourceAdapterInterface` interface: 20 | * Implement the `getStream(): StreamInterface` method. 21 | * Implement the `getHash(string $algo): string` method. 22 | * 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. 23 | * Remove the `getContents()` method. The `getStream()->getContents()` method may be used instead. 24 | * Remove the `getSource()` method. No replacement. 25 | * Remove the `path()` method. No replacement. 26 | * Remove the `valid()` method. SourceAdapters should now throw an exception with a more helpful message from the constructor if the source is not valid. 27 | * 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). 28 | * To make use of the image optimization feature: 29 | * 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. 30 | * 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. 31 | * The `ImageManipulation::usingHashForFilename()` method has been renamed to `ImageManipulation::isUsingHashForFilename()` to avoid confusion with the `useHashForFilename()` method. 32 | * `\Plank\Mediable\HandlesMediaUploadExceptions::transformMediaUploadException()` parameter and return type changed from `\Exception` to `\Throwable`. 33 | 34 | ## 4.x to 5.x 35 | 36 | * 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. 37 | * Two columns added to the `media` table: `variant_name` (varchar) and `original_media_id` (should match `media.id` column type). Migration file is included with the package. 38 | * `Plank\Mediable\MediaUploaderFacade` moved to `Plank\Mediable\Facades\MediaUploader` 39 | * Directory and filename validation now only allows URL and filesystem safe ASCII characters (alphanumeric plus `.`, `-`, `_`, and `/` for directories). Will automatically attempt to transliterate UTF-8 accented characters and ligatures into their ASCII equivalent, all other characters will be converted to hyphens. 40 | * The following methods now include an extra `$withVariants` parameter : 41 | * `Mediable::scopeWithMedia()` 42 | * `Mediable::scopeWithMediaMatchAll()` 43 | * `Mediable::loadMedia()` 44 | * `Mediable::loadMediaMatchAll()` 45 | * `MediableCollection::loadMedia()` 46 | * `MediableCollection::loadMediaMatchAll()` 47 | 48 | ## 3.x to 4.x 49 | 50 | * UrlGenerators no longer throw `MediaUrlException` when the file does not have public visibility. This removes the need to read IO for files local disks or to make HTTP calls for files on s3 disks. Visibility can still checked with `$media->isPubliclyAccessible()`, if necessary. 51 | * Highly recommended to explicitly specify the `'url'` config value on all disks used to generate URLs. 52 | * No longer reading the `'prefix'` config of local disks. Value should be included in the `'url'` config instead. 53 | 54 | ## 2.x to 3.x 55 | 56 | * Minimum PHP version moved to 7.2 57 | * Minimum Laravel version moved to 5.6 58 | * All methods now have parameter and return type hints. If extending any class or implementing any interface from this package, method signatures will need to be updated. 59 | 60 | ## 1.x to 2.x 61 | 62 | You need to add an order column to the mediables table. 63 | 64 | ```php 65 | $table->integer('order')->unsigned()->index(); 66 | ``` 67 | 68 | A handful of methods have been renamed on the `MediaUploader` class. 69 | 70 | `setFilename` -> `useFilename` 71 | `setDisk` -> `toDisk` 72 | `setDirectory` -> `toDirectory` 73 | 74 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plank/laravel-mediable", 3 | "description": "A package for easily uploading and attaching media files to models with Laravel", 4 | "keywords": [ 5 | "media", 6 | "image", 7 | "uploader", 8 | "eloquent", 9 | "laravel" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Sean Fraser", 15 | "email": "sean@plankdesign.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1.0", 20 | "ext-fileinfo": "*", 21 | "guzzlehttp/guzzle": "^7.9.1", 22 | "guzzlehttp/psr7": "^2.7", 23 | "illuminate/database": "^11.34.0|^12.0", 24 | "illuminate/filesystem": "^11.34.0|^12.0", 25 | "illuminate/support": "^11.34.0|^12.0", 26 | "intervention/image": "^2.7.1|^3.9.1", 27 | "league/flysystem": "^3.29.1", 28 | "symfony/http-foundation": "^6.0.3|^7.2", 29 | "symfony/mime": "^6.0|^7.2", 30 | "spatie/image-optimizer": "^1.8" 31 | }, 32 | "require-dev": { 33 | "aws/aws-sdk-php": "^3.340.0", 34 | "doctrine/dbal": "^3.9.3", 35 | "guzzlehttp/promises": "^1.5.1|^2.0", 36 | "laravel/legacy-factories": "^1.4.0", 37 | "league/flysystem-aws-s3-v3": "^3.29.0", 38 | "mockery/mockery": "^1.6.12", 39 | "orchestra/testbench": "^9.6.1|^10.0", 40 | "php-coveralls/php-coveralls": "^2.7.0", 41 | "phpunit/phpunit": "^11.5.10", 42 | "vlucas/phpdotenv": "^5.6.1", 43 | "phpstan/phpstan": "^2.0.3" 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Plank\\Mediable\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "Plank\\Mediable\\Tests\\": "tests/" 53 | }, 54 | "classmap": [ 55 | "migrations/" 56 | ] 57 | }, 58 | "suggest": { 59 | "intervention/image-laravel": "Laravel bindings for the intervention/image package used for image manipulation" 60 | }, 61 | "minimum-stability": "stable", 62 | "prefer-stable": true, 63 | "extra": { 64 | "laravel": { 65 | "providers": [ 66 | "Plank\\Mediable\\MediableServiceProvider" 67 | ], 68 | "aliases": { 69 | "MediaUploader": "Plank\\Mediable\\Facades\\MediaUploader", 70 | "ImageManipulator": "Plank\\Mediable\\Facades\\ImageManipulator" 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Laravel-Mediable.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Laravel-Mediable.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Laravel-Mediable" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Laravel-Mediable" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme==1.3.0 2 | -------------------------------------------------------------------------------- /docs/source/commands.rst: -------------------------------------------------------------------------------- 1 | Artisan Commands 2 | ============================================ 3 | 4 | .. highlight:: bash 5 | 6 | This package provides a handful of artisan commands to help keep you filesystem and database in sync. 7 | 8 | Create a media record in the database for any files on the disk that do not already have a record. This will apply any type restrictions in the mediable configuration file. 9 | 10 | :: 11 | 12 | $ php artisan media:import [disk] 13 | 14 | 15 | Delete any media records representing a file that no longer exists on the disk. 16 | 17 | :: 18 | 19 | $ php artisan media:prune [disk] 20 | 21 | To perform both commands together, you can use: 22 | 23 | :: 24 | 25 | $ php artisan media:sync [disk] 26 | -------------------------------------------------------------------------------- /docs/source/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ======================== 3 | 4 | .. highlight:: php 5 | 6 | 7 | .. _disks: 8 | 9 | Disks 10 | ------------------------ 11 | Laravel-Mediable is built on top of Laravel's Filesystem component. Before you use the package, you will need to configure the filesystem disks where you would like files to be stored in ``config/filesystems.php``. `Learn more about filesystem disk `_. 12 | 13 | :: 14 | 15 | [ 18 | 'local' => [ 19 | 'driver' => 'local', 20 | 'root' => storage_path('app'), 21 | 'url' => 'https://example.com/storage/app', 22 | 'visibility' => 'public' 23 | ], 24 | 25 | 'uploads' => [ 26 | 'driver' => 'local', 27 | 'root' => public_path('uploads'), 28 | 'url' => 'https://example.com/uploads', 29 | 'visibility' => 'public' 30 | ], 31 | ] 32 | //... 33 | 34 | 35 | Once you have set up as many disks as you need, edit ``config/mediable.php`` to authorize the package to use the disks you have created. 36 | 37 | :: 38 | 39 | 'uploads', 45 | 46 | /* 47 | * Filesystems that can be used for media storage 48 | */ 49 | 'allowed_disks' => [ 50 | 'local', 51 | 'uploads', 52 | ], 53 | //... 54 | 55 | 56 | .. _validation: 57 | 58 | Validation 59 | ------------------------ 60 | 61 | The `config/mediable.php` offers a number of options for configuring how media uploads are validated. These values serve as defaults, which can be overridden on a case-by-case basis for each ``MediaUploader`` instance. 62 | 63 | :: 64 | 65 | 1024 * 1024 * 10, 71 | 72 | /* 73 | * What to do if a duplicate file is uploaded. Options include: 74 | * 75 | * * 'increment': the new file's name is given an incrementing suffix 76 | * * 'replace' : the old file and media model is deleted 77 | * * 'error': an Exception is thrown 78 | * 79 | */ 80 | 'on_duplicate' => Plank\Mediable\MediaUploader::ON_DUPLICATE_INCREMENT, 81 | 82 | /* 83 | * Reject files unless both their mime and extension are recognized and both match a single aggregate type 84 | */ 85 | 'strict_type_checking' => false, 86 | 87 | /* 88 | * Reject files whose mime type or extension is not recognized 89 | * if true, files will be given a type of `'other'` 90 | */ 91 | 'allow_unrecognized_types' => false, 92 | 93 | /* 94 | * Only allow files with specific MIME type(s) to be uploaded 95 | */ 96 | 'allowed_mime_types' => [], 97 | 98 | /* 99 | * Only allow files with specific file extension(s) to be uploaded 100 | */ 101 | 'allowed_extensions' => [], 102 | 103 | /* 104 | * Only allow files matching specific aggregate type(s) to be uploaded 105 | */ 106 | 'allowed_aggregate_types' => [], 107 | //... 108 | 109 | .. _aggregate_types: 110 | 111 | Aggregate Types 112 | ------------------------ 113 | 114 | Laravel-Mediable provides functionality for handling multiple kinds of files under a shared aggregate type. This is intended to make it easy to find similar media without needing to constantly juggle multiple MIME types or file extensions. 115 | 116 | The package defines a number of common file types in the config file (``config/mediable.php``). Feel free to modify the default types provided by the package or add your own. Each aggregate type requires a key used to identify the type and a list of MIME types and file extensions that should be recognized as belonging to that aggregate type. For example, if you wanted to add an aggregate type for different types of markup, you could do the following. 117 | 118 | :: 119 | 120 | [ 123 | //... 124 | 'markup' => [ 125 | 'mime_types' => [ 126 | 'text/markdown', 127 | 'text/html', 128 | 'text/xml', 129 | 'application/xml', 130 | 'application/xhtml+xml', 131 | ], 132 | 'extensions' => [ 133 | 'md', 134 | 'html', 135 | 'htm', 136 | 'xhtml', 137 | 'xml' 138 | ] 139 | ], 140 | //... 141 | ] 142 | //... 143 | 144 | 145 | Note: a MIME type or extension could be present in more than one aggregate type's definitions (the system will try to find the best match), but each Media record can only have one aggregate type. 146 | 147 | .. _extending_functionality: 148 | 149 | Extending functionality 150 | ------------------------ 151 | 152 | The ``config/mediable.php`` file lets you specify a number of classes to be use for internal behaviour. This is to allow for extending some of the the default classes used by the package or to cover additional use cases. 153 | 154 | :: 155 | 156 | Plank\Mediable\Media::class, 164 | 165 | /* 166 | * List of adapters to use for various source inputs 167 | * 168 | * Adapters can map either to a class or a pattern (regex) 169 | */ 170 | 'source_adapters' => [ 171 | 'class' => [ 172 | Symfony\Component\HttpFoundation\File\UploadedFile::class => Plank\Mediable\SourceAdapters\UploadedFileAdapter::class, 173 | Symfony\Component\HttpFoundation\File\File::class => Plank\Mediable\SourceAdapters\FileAdapter::class, 174 | Psr\Http\Message\StreamInterface::class => Plank\Mediable\SourceAdapters\StreamAdapter::class, 175 | ], 176 | 'pattern' => [ 177 | '^https?://' => Plank\Mediable\SourceAdapters\RemoteUrlAdapter::class, 178 | '^/' => Plank\Mediable\SourceAdapters\LocalPathAdapter::class 179 | ], 180 | ], 181 | 182 | /* 183 | * List of URL Generators to use for handling various filesystem disks 184 | */ 185 | 'url_generators' => [ 186 | 'local' => Plank\Mediable\UrlGenerators\LocalUrlGenerator::class, 187 | 's3' => Plank\Mediable\UrlGenerators\S3UrlGenerator::class, 188 | ], 189 | 190 | It is also possible to define the connection name that Laravel-Mediable will use to access the database. 191 | 192 | :: 193 | 194 | null, 202 | //... 203 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Plank/Laravel-Mediable 2 | ============================================ 3 | 4 | .. image:: https://img.shields.io/coveralls/plank/laravel-mediable.svg?style=flat-square 5 | :target: https://coveralls.io/github/plank/laravel-mediable 6 | :alt: Coveralls 7 | .. image:: https://styleci.io/repos/63791110/shield 8 | :target: https://styleci.io/repos/63791110 9 | :alt: StyleCI 10 | .. image:: https://img.shields.io/packagist/v/plank/laravel-mediable.svg?style=flat-square 11 | :target: https://packagist.org/packages/plank/laravel-mediable 12 | :alt: Packagist 13 | 14 | Laravel-Mediable is a package for easily uploading and attaching media files to models with Laravel 5. 15 | 16 | Features 17 | ------------- 18 | 19 | * Filesystem-driven approach is easily configurable to allow any number of upload directories with different accessibility. Easily restrict uploads by MIME type, extension and/or aggregate type (e.g. ``image`` for JPEG, PNG or GIF). 20 | * Many-to-many polymorphic relationships allow any number of media to be assigned to any number of other models without any need to modify their schema. 21 | * Attach media to models with tags, in order to set and retrieve media for specific purposes, such as ``'thumbnail'``, ``'featured image'``, ``'gallery'`` or ``'download'``. 22 | * Integrated support for integration/image for manipulating image files to create variants for different use cases. 23 | 24 | 25 | .. toctree:: 26 | :maxdepth: 2 27 | :caption: Getting Started 28 | 29 | installation 30 | configuration 31 | 32 | .. toctree:: 33 | :maxdepth: 2 34 | :caption: Guides 35 | 36 | uploader 37 | mediable 38 | media 39 | types 40 | variants 41 | commands 42 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============================================ 3 | 4 | Add the package to your Laravel app using composer. 5 | 6 | :: 7 | 8 | $ composer require plank/laravel-mediable 9 | 10 | 11 | Register the package's service provider in `config/app.php`. In Laravel versions 5.5 and beyond, this step can be skipped if package auto-discovery is enabled. 12 | 13 | :: 14 | 15 | 'providers' => [ 16 | //... 17 | Plank\Mediable\MediableServiceProvider::class, 18 | //... 19 | ]; 20 | 21 | The package comes with a Facade for the image uploader, which you can optionally register as well. In Laravel versions 5.5 and beyond, this step can be skipped if package auto-discovery is enabled. 22 | 23 | :: 24 | 25 | 'aliases' => [ 26 | //... 27 | 'MediaUploader' => Plank\Mediable\Facades\MediaUploader::class, 28 | //... 29 | ] 30 | 31 | 32 | Publish the config file (``config/mediable.php``) and migration file (``database/migrations/####_##_##_######_create_mediable_tables.php``) of the package using artisan. 33 | 34 | :: 35 | 36 | $ php artisan vendor:publish --provider="Plank\Mediable\MediableServiceProvider" 37 | 38 | Run the migrations to add the required tables to your database. 39 | 40 | :: 41 | 42 | $ php artisan migrate 43 | 44 | 45 | Quickstart 46 | ----------- 47 | 48 | Add the `Mediable` trait and `MediableInterface` interface to your eloquent models 49 | 50 | :: 51 | 52 | file('thumbnail')) 73 | ->toDestination('s3', 'posts/thumbnails') 74 | ->upload(); 75 | 76 | Attach the records to your models. 77 | 78 | :: 79 | 80 | attachMedia($media, 'thumbnail'); 83 | 84 | Load and display your files 85 | 86 | :: 87 | 88 | find($postId); 90 | echo $post->getMedia('thumbnail')->first()->getUrl(); 91 | -------------------------------------------------------------------------------- /docs/source/media.rst: -------------------------------------------------------------------------------- 1 | Using Media 2 | ============ 3 | 4 | .. highlight:: php 5 | 6 | Media Paths 7 | --------------------- 8 | 9 | ``Media`` records keep track of the location of their file and are able to generate a number of paths relative to the file. Consider the following example, given a ``Media`` instance with the following attributes: 10 | 11 | 12 | :: 13 | 14 | [ 15 | 'disk' => 'uploads', 16 | 'directory' => 'foo/bar', 17 | 'filename' => 'picture', 18 | 'extension' => 'jpg' 19 | // ... 20 | ]; 21 | 22 | The following attributes and methods would be exposed: 23 | 24 | :: 25 | 26 | getAbsolutePath(); 28 | // /var/www/site/public/uploads/foo/bar/picture.jpg 29 | 30 | $media->getDiskPath(); 31 | // foo/bar/picture.jpg 32 | 33 | $media->directory; 34 | // foo/bar 35 | 36 | $media->basename; 37 | // picture.jpg 38 | 39 | $media->filename; 40 | // picture 41 | 42 | $media->extension; 43 | // jpg 44 | 45 | URLs and Downloads 46 | --------------------- 47 | 48 | URLs can be generated for Media stored on a public disk and set to public visibility. 49 | 50 | :: 51 | 52 | $media->getUrl(); 53 | // http://localhost/uploads/foo/bar/picture.jpg 54 | 55 | `$media->getUrl()` will throw an exception if the file or its disk has its visibility set to private. You can check if it is safe to generate a url for a record with the `$media->isPubliclyAccessible()` method. 56 | 57 | For private files stored on an Amazon S3 disk, it is possible to generate a temporary signed URL to allow authorized users the ability to download the file for a specified period of time. 58 | 59 | :: 60 | 61 | getTemporaryUrl(Carbon::now->addMinutes(5)); 63 | 64 | For private files, it is possible to expose them to authorized users by streaming the file from the server. 65 | 66 | :: 67 | 68 | streamDownload( 70 | function() use ($media) { 71 | $stream = $media->stream(); 72 | while($bytes = $stream->read(1024)) { 73 | echo $bytes; 74 | } 75 | }, 76 | $media->basename, 77 | [ 78 | 'Content-Type' => $media->mime_type, 79 | 'Content-Length' => $media->size 80 | ] 81 | ); 82 | 83 | Querying Media 84 | --------------------- 85 | 86 | If you need to query the media table directly, rather than through associated models, the Media class exposes a few helpful methods for the query builder. 87 | 88 | :: 89 | 90 | move('new/directory'); 108 | $media->move('new/directory', 'new-filename'); 109 | $media->rename('new-filename'); 110 | $media->moveToDisk('uploads', 'new/directory', 'new-filename'); 111 | 112 | Copying Media 113 | --------------------- 114 | 115 | You can duplicate a media file to a different location on disk with the ``copyTo()`` method. Doing so will create a new ``Media`` record for the new file. If a filename is not provided, the new file will copy the original filename. 116 | 117 | :: 118 | 119 | copyTo('new/directory'); 121 | $newMedia = $media->copyTo('new/directory', 'new-filename'); 122 | $newMedia = $media->copyToDisk('uploads', 'new/directory', 'new-filename'); 123 | 124 | :Note: Both ``moveToDisk()`` and ``copyToDisk()`` support passing an additional ``$options`` argument with flags to be passed to the underlying filesystem adapter of the destination disk. 125 | 126 | Deleting Media 127 | --------------------- 128 | 129 | You can delete media with standard Eloquent model ``delete()`` method. This will also delete the file associated with the record and detach any associated ``Mediable`` models. 130 | 131 | :: 132 | 133 | delete(); 135 | 136 | 137 | **Note**: The ``delete()`` method on the query builder *will not* delete the associated file. It will still purge relationships due to the cascading foreign key. 138 | 139 | :: 140 | 141 | delete(); //will not delete files 143 | 144 | Soft Deletes 145 | ^^^^^^^^^^^^ 146 | 147 | If you subclass the ``Media`` class and add Laravel's ``SoftDeletes`` trait, the media will only delete its associated file and detach its relationship if ``forceDelete()`` is used. 148 | 149 | You can change the ``detach_on_soft_delete`` setting to ``true`` in ``config/mediable.php`` to have relationships automatically detach when either the ``Media`` record or ``Mediable`` model are soft deleted. 150 | 151 | Setting Visibility 152 | --------------------- 153 | 154 | You can update the visibility of a `Media` record's file 155 | 156 | :: 157 | 158 | makePublic(); 160 | $media->makePrivate(); 161 | -------------------------------------------------------------------------------- /docs/source/types.rst: -------------------------------------------------------------------------------- 1 | Aggregate Types 2 | =============== 3 | 4 | .. highlight:: php 5 | 6 | Laravel-Mediable provides functionality for handling multiple kinds of files under a shared aggregate type. This is intended to make it easy to find similar media without needing to constantly juggle multiple MIME types or file extensions. For example, you might want to query for an image, but not care if it is in JPEG, PNG or GIF format. 7 | 8 | :: 9 | 10 | get(); 12 | 13 | 14 | You can use this functionality to restrict the uploader to only accept certain types of files. 15 | 16 | :: 17 | 18 | file('thumbnail')) 20 | ->toDestination('uploads', '') 21 | ->setAllowedAggregateTypes([Media::TYPE_IMAGE, Media::TYPE_IMAGE_VECTOR]) 22 | ->upload() 23 | 24 | To customize the aggregate type definitions for your project, see :ref:`Configuring Aggregate Types `. 25 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 10, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "logs": { 9 | "text": "infection\/infection.log" 10 | }, 11 | "mutators": { 12 | "@default": true 13 | } 14 | } -------------------------------------------------------------------------------- /migrations/2016_06_27_000000_create_mediable_tables.php: -------------------------------------------------------------------------------- 1 | id(); 21 | $table->string('disk', 32); 22 | $table->string('directory'); 23 | $table->string('filename'); 24 | $table->string('extension', 32); 25 | $table->string('mime_type', 128); 26 | $table->string('aggregate_type', 32)->index(); 27 | $table->unsignedInteger('size'); 28 | $table->timestamps(); 29 | $table->unique(['disk', 'directory', 'filename', 'extension']); 30 | } 31 | ); 32 | } 33 | 34 | if (!Schema::hasTable('mediables')) { 35 | Schema::create( 36 | 'mediables', 37 | function (Blueprint $table) { 38 | $table->foreignIdFor(Media::class)->constrained('media')->cascadeOnDelete(); 39 | $table->morphs('mediable'); 40 | $table->string('tag')->index(); 41 | $table->unsignedInteger('order')->index(); 42 | $table->primary(['media_id', 'mediable_type', 'mediable_id', 'tag']); 43 | $table->index(['mediable_id', 'mediable_type']); 44 | } 45 | ); 46 | } 47 | } 48 | 49 | /** 50 | * Reverse the migrations. 51 | * 52 | * @return void 53 | */ 54 | public function down(): void 55 | { 56 | Schema::dropIfExists('mediables'); 57 | Schema::dropIfExists('media'); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function getConnection() 64 | { 65 | return config('mediable.connection_name', parent::getConnection()); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /migrations/2020_10_12_000000_add_variants_to_media.php: -------------------------------------------------------------------------------- 1 | string('variant_name', 255) 22 | ->after('size') 23 | ->nullable(); 24 | } 25 | ); 26 | Schema::whenTableDoesntHaveColumn( 27 | 'media', 28 | 'original_media_id', 29 | function (Blueprint $table) { 30 | $table->foreignIdFor(Media::class, 'original_media_id') 31 | ->nullable() 32 | ->after('variant_name') 33 | ->constrained('media') 34 | ->nullOnDelete(); 35 | } 36 | ); 37 | } 38 | 39 | /** 40 | * Reverse the migrations. 41 | * 42 | * @return void 43 | */ 44 | public function down(): void 45 | { 46 | Schema::whenTableHasColumn( 47 | 'media', 48 | 'original_media_id', 49 | function (Blueprint $table) { 50 | // SQLite does not support dropping foreign keys or columns with constraints 51 | // skip removing this column, the `whenTableDoesntHaveColumn` 52 | // method should make this safe to play back 53 | if (DB::getDriverName() !== 'sqlite') { 54 | $table->dropConstrainedForeignIdFor(Media::class, 'original_media_id'); 55 | } 56 | } 57 | ); 58 | Schema::whenTableHasColumn( 59 | 'media', 60 | 'variant_name', 61 | function (Blueprint $table) { 62 | $table->dropColumn('variant_name'); 63 | } 64 | ); 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function getConnection() 71 | { 72 | return config('mediable.connection_name', parent::getConnection()); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /migrations/2024_03_30_000000_add_alt_to_media.php: -------------------------------------------------------------------------------- 1 | text('alt')->nullable(); 22 | } 23 | ); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down():void 32 | { 33 | Schema::whenTableHasColumn( 34 | 'media', 35 | 'alt', 36 | function (Blueprint $table) { 37 | $table->dropColumn('alt'); 38 | } 39 | ); 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function getConnection() 46 | { 47 | return config('mediable.connection_name', parent::getConnection()); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 1 3 | paths: 4 | - src 5 | - tests 6 | excludePaths: 7 | analyse: 8 | - vendor 9 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests/Integration/ 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ./src/ 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Commands/ImportMediaCommand.php: -------------------------------------------------------------------------------- 1 | 0, 43 | 'updated' => 0, 44 | 'skipped' => 0, 45 | ]; 46 | 47 | public function __construct(FileSystemManager $filesystem, MediaUploader $uploader) 48 | { 49 | parent::__construct(); 50 | $this->filesystem = $filesystem; 51 | $this->uploader = $uploader; 52 | } 53 | 54 | /** 55 | * Execute the console command. 56 | * 57 | * @return void 58 | */ 59 | public function handle(): void 60 | { 61 | $this->resetCounters(); 62 | 63 | $disk = $this->argument('disk'); 64 | $directory = $this->option('directory') ?: ''; 65 | $recursive = !$this->option('non-recursive'); 66 | $force = (bool)$this->option('force'); 67 | 68 | $files = $this->listFiles($disk, $directory, $recursive); 69 | $existing_media = $this->makeModel() 70 | ->inDirectory($disk, $directory, $recursive) 71 | ->get(); 72 | 73 | foreach ($files as $path) { 74 | if ($record = $this->getRecordForFile($path, $existing_media)) { 75 | if ($force) { 76 | $this->updateRecordForFile($record, $path); 77 | } 78 | } else { 79 | $this->createRecordForFile($disk, $path); 80 | } 81 | } 82 | 83 | $this->outputCounters(); 84 | } 85 | 86 | /** 87 | * Generate a list of all files in the specified directory. 88 | * @param string $disk 89 | * @param string $directory 90 | * @param bool $recursive 91 | * @return array 92 | */ 93 | protected function listFiles(string $disk, string $directory = '', bool $recursive = true): array 94 | { 95 | if ($recursive) { 96 | return $this->filesystem->disk($disk)->allFiles($directory); 97 | } else { 98 | return $this->filesystem->disk($disk)->files($directory); 99 | } 100 | } 101 | 102 | /** 103 | * Search through the record list for one matching the provided path. 104 | * @param string $path 105 | * @param Collection $existingMedia 106 | * @return Media|null 107 | */ 108 | protected function getRecordForFile(string $path, Collection $existingMedia): ?Media 109 | { 110 | $directory = File::cleanDirname($path); 111 | $filename = pathinfo($path, PATHINFO_FILENAME); 112 | $extension = pathinfo($path, PATHINFO_EXTENSION); 113 | 114 | return $existingMedia->filter(function (Media $media) use ($directory, $filename, $extension) { 115 | return $media->directory == $directory && $media->filename == $filename && $media->extension == $extension; 116 | })->first(); 117 | } 118 | 119 | /** 120 | * Generate a new media record. 121 | * @param string $disk 122 | * @param string $path 123 | * @return void 124 | */ 125 | protected function createRecordForFile(string $disk, string $path): void 126 | { 127 | try { 128 | $this->uploader->importPath($disk, $path); 129 | ++$this->counters['created']; 130 | $this->info("Created Record for file at {$path}", 'v'); 131 | } catch (MediaUploadException $e) { 132 | $this->warn($e->getMessage(), 'vvv'); 133 | ++$this->counters['skipped']; 134 | $this->info("Skipped file at {$path}", 'v'); 135 | } 136 | } 137 | 138 | /** 139 | * Update an existing media record. 140 | * @param \Plank\Mediable\Media $media 141 | * @param string $path 142 | * @return void 143 | */ 144 | protected function updateRecordForFile(Media $media, string $path): void 145 | { 146 | try { 147 | if ($this->uploader->update($media)) { 148 | ++$this->counters['updated']; 149 | $this->info("Updated record for {$path}", 'v'); 150 | } else { 151 | ++$this->counters['skipped']; 152 | $this->info("Skipped unmodified file at {$path}", 'v'); 153 | } 154 | } catch (MediaUploadException $e) { 155 | $this->warn($e->getMessage(), 'vvv'); 156 | ++$this->counters['skipped']; 157 | $this->info("Skipped file at {$path}", 'v'); 158 | } 159 | } 160 | 161 | /** 162 | * Send the counter total to the console. 163 | * @return void 164 | */ 165 | protected function outputCounters(): void 166 | { 167 | $this->info(sprintf('Imported %d file(s).', $this->counters['created'])); 168 | if ($this->counters['updated'] > 0) { 169 | $this->info(sprintf('Updated %d record(s).', $this->counters['updated'])); 170 | } 171 | if ($this->counters['skipped'] > 0) { 172 | $this->info(sprintf('Skipped %d file(s).', $this->counters['skipped'])); 173 | } 174 | } 175 | 176 | /** 177 | * Reset the counters of processed files. 178 | * @return void 179 | */ 180 | protected function resetCounters(): void 181 | { 182 | $this->counters = [ 183 | 'created' => 0, 184 | 'updated' => 0, 185 | 'skipped' => 0, 186 | ]; 187 | } 188 | 189 | /** 190 | * Generate an instance of the `Media` class. 191 | * @return Media 192 | */ 193 | private function makeModel(): Media 194 | { 195 | $class = config('mediable.model', Media::class); 196 | 197 | return new $class; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Commands/PruneMediaCommand.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 35 | } 36 | 37 | /** 38 | * Execute the console command. 39 | * 40 | * @return void 41 | */ 42 | public function handle(): void 43 | { 44 | $disk = $this->argument('disk'); 45 | $directory = $this->option('directory') ?: ''; 46 | $recursive = !$this->option('non-recursive'); 47 | $counter = 0; 48 | 49 | $records = $this->makeModel() 50 | ->newQuery() 51 | ->inDirectory($disk, $directory, $recursive) 52 | ->get(); 53 | 54 | foreach ($records as $media) { 55 | if (!$media->fileExists()) { 56 | $media->delete(); 57 | ++$counter; 58 | $this->info("Pruned record for file {$media->getDiskPath()}", 'v'); 59 | } 60 | } 61 | 62 | $this->info("Pruned {$counter} record(s)."); 63 | } 64 | 65 | /** 66 | * Generate an instance of the `Media` class. 67 | * @return Media 68 | */ 69 | private function makeModel(): Media 70 | { 71 | $class = config('mediable.model', Media::class); 72 | 73 | return new $class; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Commands/SyncMediaCommand.php: -------------------------------------------------------------------------------- 1 | argument('disk'); 36 | $directory = $this->option('directory') ?: ''; 37 | $non_recursive = (bool)$this->option('non-recursive'); 38 | $force = (bool)$this->option('force'); 39 | 40 | $this->call('media:prune', [ 41 | 'disk' => $disk, 42 | '--directory' => $directory, 43 | '--non-recursive' => $non_recursive, 44 | ]); 45 | 46 | $this->call('media:import', [ 47 | 'disk' => $disk, 48 | '--directory' => $directory, 49 | '--non-recursive' => $non_recursive, 50 | '--force' => $force, 51 | ]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Exceptions/ImageManipulationException.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class MediaUrlException extends Exception 12 | { 13 | public static function generatorNotFound(string $disk, string $driver): self 14 | { 15 | return new self("Could not find UrlGenerator for disk `{$disk}` of type `{$driver}`"); 16 | } 17 | 18 | public static function invalidGenerator(string $class): self 19 | { 20 | return new self("Could not set UrlGenerator, class `{$class}` does not extend `Plank\Mediable\UrlGenerators\UrlGenerator`"); 21 | } 22 | 23 | public static function temporaryUrlsNotSupported(string $disk): self 24 | { 25 | return new self("Temporary URLs are not supported for files on disk '{$disk}'"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Facades/ImageManipulator.php: -------------------------------------------------------------------------------- 1 | [ 26 | ForbiddenException::class, 27 | ], 28 | 29 | // 404 30 | Response::HTTP_NOT_FOUND => [ 31 | FileNotFoundException::class, 32 | ], 33 | 34 | // 409 35 | Response::HTTP_CONFLICT => [ 36 | FileExistsException::class, 37 | ], 38 | 39 | // 413 40 | Response::HTTP_REQUEST_ENTITY_TOO_LARGE => [ 41 | FileSizeException::class, 42 | ], 43 | 44 | // 415 45 | Response::HTTP_UNSUPPORTED_MEDIA_TYPE => [ 46 | FileNotSupportedException::class, 47 | ], 48 | ]; 49 | 50 | /** 51 | * Transform a MediaUploadException into an HttpException. 52 | * 53 | * @param \Throwable $e 54 | * @return \Throwable 55 | */ 56 | protected function transformMediaUploadException(\Throwable $e): \Throwable 57 | { 58 | if ($e instanceof MediaUploadException) { 59 | $status_code = $this->getStatusCodeForMediaUploadException($e); 60 | return new HttpException($status_code, $e->getMessage(), $e); 61 | } 62 | 63 | return $e; 64 | } 65 | 66 | /** 67 | * Get the appropriate HTTP status code for the exception. 68 | * 69 | * @param MediaUploadException $e 70 | * @return integer 71 | */ 72 | private function getStatusCodeForMediaUploadException(MediaUploadException $e): int 73 | { 74 | foreach ($this->status_codes as $status_code => $exceptions) { 75 | if (in_array(get_class($e), $exceptions)) { 76 | return $status_code; 77 | } 78 | } 79 | 80 | return 500; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Helpers/File.php: -------------------------------------------------------------------------------- 1 | getExtensions($mimeType)[0] ?? null; 95 | } 96 | 97 | public static function joinPathComponents(string ...$components): string 98 | { 99 | $path = ''; 100 | foreach ($components as $component) { 101 | if (empty($component)) { 102 | continue; 103 | } 104 | if (empty($path)) { 105 | $path = $component; 106 | continue; 107 | } 108 | $path = rtrim($path, '/') . '/' . ltrim($component, '/'); 109 | } 110 | return $path; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/ImageOptimizer.php: -------------------------------------------------------------------------------- 1 | getTmpFile(); 15 | $tmpStream = Utils::streamFor(Utils::tryFopen($tmpPath, 'wb')); 16 | Utils::copyToStream($imageStream, $tmpStream); 17 | $optimizerChain->optimize($tmpPath); 18 | // open a separate stream to detect the changes made by the optimizers 19 | return Utils::streamFor(Utils::tryFopen($tmpPath, 'rb')); 20 | } 21 | 22 | private function getTmpFile(): string 23 | { 24 | $tmpFile = tempnam(sys_get_temp_dir(), 'mediable-'); 25 | if ($tmpFile === false) { 26 | throw new \RuntimeException( 27 | 'Could not create temporary file. The system temp directory may not be writable.' 28 | ); 29 | } 30 | return $tmpFile; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Jobs/CreateImageVariants.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private Collection $models; 27 | 28 | /** 29 | * @var bool 30 | */ 31 | private bool $forceRecreate; 32 | 33 | /** 34 | * CreateImageVariants constructor. 35 | * @param Media|Collection|Media[] $models 36 | * @param string|string[] $variantNames 37 | * @throws ImageManipulationException 38 | */ 39 | public function __construct($models, $variantNames, bool $forceRecreate = false) 40 | { 41 | $models = $this->collect($models); 42 | $variantNames = (array) $variantNames; 43 | $this->validate($models, $variantNames); 44 | 45 | $this->variantNames = $variantNames; 46 | $this->models = $models; 47 | $this->forceRecreate = $forceRecreate; 48 | } 49 | 50 | public function handle(): void 51 | { 52 | foreach ($this->getModels() as $model) { 53 | foreach ($this->getVariantNames() as $variantName) { 54 | $this->getImageManipulator()->createImageVariant( 55 | $model, 56 | $variantName, 57 | $this->getForceRecreate() 58 | ); 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * @return string[] 65 | */ 66 | public function getVariantNames(): array 67 | { 68 | return $this->variantNames; 69 | } 70 | 71 | /** 72 | * @return Collection|Media[] 73 | */ 74 | public function getModels(): Collection 75 | { 76 | return $this->models; 77 | } 78 | 79 | /** 80 | * @param Collection $models 81 | * @param array $variantNames 82 | * @throws ImageManipulationException 83 | */ 84 | private function validate(Collection $models, array $variantNames): void 85 | { 86 | $imageManipulator = $this->getImageManipulator(); 87 | foreach ($models as $media) { 88 | $imageManipulator->validateMedia($media); 89 | } 90 | foreach ($variantNames as $variantName) { 91 | $imageManipulator->getVariantDefinition($variantName); 92 | } 93 | } 94 | 95 | private function getImageManipulator(): ImageManipulator 96 | { 97 | return app(ImageManipulator::class); 98 | } 99 | 100 | /** 101 | * @return bool 102 | */ 103 | public function getForceRecreate(): bool 104 | { 105 | return $this->forceRecreate; 106 | } 107 | 108 | /** 109 | * @param Media|Collection|Media[] $models 110 | * @return Collection 111 | */ 112 | private function collect($models): Collection 113 | { 114 | if ($models instanceof Media) { 115 | $models = [$models]; 116 | } 117 | return new Collection($models); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/MediaMover.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 21 | } 22 | 23 | /** 24 | * Move the file to a new location on disk. 25 | * 26 | * Will invoke the `save()` method on the model after the associated file has been moved to prevent synchronization errors 27 | * @param Media $media 28 | * @param string $directory directory relative to disk root 29 | * @param string $filename filename. Do not include extension 30 | * @return void 31 | * @throws MediaMoveException If attempting to change the file extension or a file with the same name already exists at the destination 32 | */ 33 | public function move(Media $media, string $directory, ?string $filename = null): void 34 | { 35 | $storage = $this->filesystem->disk($media->disk); 36 | 37 | $filename = $this->cleanFilename($media, $filename); 38 | $directory = File::sanitizePath($directory); 39 | $targetPath = $directory . '/' . $filename . '.' . $media->extension; 40 | 41 | if ($storage->exists($targetPath)) { 42 | throw MediaMoveException::destinationExists($targetPath); 43 | } 44 | 45 | $storage->move($media->getDiskPath(), $targetPath); 46 | 47 | $media->filename = $filename; 48 | $media->directory = $directory; 49 | $media->save(); 50 | } 51 | 52 | /** 53 | * Move the file to a new location on another disk. 54 | * 55 | * Will invoke the `save()` method on the model after the associated file has been moved to prevent synchronization errors 56 | * @param Media $media 57 | * @param string $disk the disk to move the file to 58 | * @param string $directory directory relative to disk root 59 | * @param string $filename filename. Do not include extension 60 | * @param array $options additional options to pass to the disk driver when uploading the file 61 | * @return void 62 | * @throws MediaMoveException If attempting to change the file extension or a file with the same name already exists at the destination 63 | */ 64 | public function moveToDisk( 65 | Media $media, 66 | string $disk, 67 | string $directory, 68 | ?string $filename = null, 69 | array $options = [] 70 | ): void { 71 | if ($media->disk === $disk) { 72 | $this->move($media, $directory, $filename); 73 | return; 74 | } 75 | 76 | $currentStorage = $this->filesystem->disk($media->disk); 77 | $targetStorage = $this->filesystem->disk($disk); 78 | 79 | $filename = $this->cleanFilename($media, $filename); 80 | $directory = File::sanitizePath($directory); 81 | $targetPath = $directory . '/' . $filename . '.' . $media->extension; 82 | 83 | if ($targetStorage->exists($targetPath)) { 84 | throw MediaMoveException::destinationExistsOnDisk($disk, $targetPath); 85 | } 86 | 87 | try { 88 | if (!isset($options['visibility'])) { 89 | $options['visibility'] = $currentStorage->getVisibility($media->getDiskPath()); 90 | } 91 | 92 | $targetStorage->put($targetPath, $currentStorage->readStream($media->getDiskPath()), $options); 93 | $currentStorage->delete($media->getDiskPath()); 94 | } catch (FileNotFoundException $e) { 95 | throw MediaMoveException::fileNotFound($media->disk, $media->getDiskPath(), $e); 96 | } 97 | 98 | $media->disk = $disk; 99 | $media->filename = $filename; 100 | $media->directory = $directory; 101 | $media->save(); 102 | } 103 | 104 | /** 105 | * Copy the file from one Media object to another one. 106 | * 107 | * This method creates a new Media object as well as duplicates the associated file on the disk. 108 | * 109 | * @param Media $media The media to copy from 110 | * @param string $directory directory relative to disk root 111 | * @param string $filename optional filename. Do not include extension 112 | * 113 | * @return Media 114 | * @throws MediaMoveException If a file with the same name already exists at the destination or it fails to copy the file 115 | */ 116 | public function copyTo(Media $media, string $directory, ?string $filename = null): Media 117 | { 118 | $storage = $this->filesystem->disk($media->disk); 119 | 120 | $filename = $this->cleanFilename($media, $filename); 121 | $directory = File::sanitizePath($directory); 122 | 123 | $targetPath = $directory . '/' . $filename . '.' . $media->extension; 124 | 125 | if ($storage->exists($targetPath)) { 126 | throw MediaMoveException::destinationExists($targetPath); 127 | } 128 | 129 | try { 130 | $storage->copy($media->getDiskPath(), $targetPath); 131 | } catch (\Exception $e) { 132 | throw MediaMoveException::failedToCopy($media->getDiskPath(), $targetPath, $e); 133 | } 134 | 135 | // now we copy the Media object 136 | /** @var Media $newMedia */ 137 | $newMedia = $media->replicate(); 138 | $newMedia->filename = $filename; 139 | $newMedia->directory = $directory; 140 | 141 | $newMedia->save(); 142 | 143 | return $newMedia; 144 | } 145 | 146 | /** 147 | * Copy the file from one Media object to another one on a different disk. 148 | * 149 | * This method creates a new Media object as well as duplicates the associated file on the disk. 150 | * 151 | * @param Media $media The media to copy from 152 | * @param string $disk the disk to copy the file to 153 | * @param string $directory directory relative to disk root 154 | * @param string $filename optional filename. Do not include extension 155 | * 156 | * @return Media 157 | * @throws MediaMoveException If a file with the same name already exists at the destination or it fails to copy the file 158 | */ 159 | public function copyToDisk( 160 | Media $media, 161 | string $disk, 162 | string $directory, 163 | ?string $filename = null, 164 | array $options = [] 165 | ): Media { 166 | if ($media->disk === $disk) { 167 | return $this->copyTo($media, $directory, $filename); 168 | } 169 | 170 | $currentStorage = $this->filesystem->disk($media->disk); 171 | $targetStorage = $this->filesystem->disk($disk); 172 | 173 | $filename = $this->cleanFilename($media, $filename); 174 | $directory = File::sanitizePath($directory); 175 | $targetPath = $directory . '/' . $filename . '.' . $media->extension; 176 | 177 | if ($targetStorage->exists($targetPath)) { 178 | throw MediaMoveException::destinationExistsOnDisk($disk, $targetPath); 179 | } 180 | 181 | try { 182 | if (!isset($options['visibility'])) { 183 | $options['visibility'] = $currentStorage->getVisibility($media->getDiskPath()); 184 | } 185 | $targetStorage->put($targetPath, $currentStorage->readStream($media->getDiskPath()), $options); 186 | } catch (FileNotFoundException $e) { 187 | throw MediaMoveException::fileNotFound($media->disk, $media->getDiskPath(), $e); 188 | } 189 | 190 | // now we copy the Media object 191 | /** @var Media $newMedia */ 192 | $newMedia = $media->replicate(); 193 | $newMedia->disk = $disk; 194 | $newMedia->filename = $filename; 195 | $newMedia->directory = $directory; 196 | 197 | $newMedia->save(); 198 | 199 | return $newMedia; 200 | } 201 | 202 | protected function cleanFilename(Media $media, ?string $filename): string 203 | { 204 | if ($filename) { 205 | return File::sanitizeFileName( 206 | $this->removeExtensionFromFilename($filename, $media->extension) 207 | ); 208 | } 209 | 210 | return $media->filename; 211 | } 212 | 213 | /** 214 | * Remove the media's extension from a filename. 215 | * @param string $filename 216 | * @param string $extension 217 | * @return string 218 | */ 219 | protected function removeExtensionFromFilename(string $filename, string $extension): string 220 | { 221 | $extension = '.' . $extension; 222 | $extensionLength = mb_strlen($filename) - mb_strlen($extension); 223 | if (mb_strrpos($filename, $extension) === $extensionLength) { 224 | $filename = mb_substr($filename, 0, $extensionLength); 225 | } 226 | 227 | return $filename; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/MediableCollection.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class MediableCollection extends Collection 20 | { 21 | /** 22 | * Lazy eager load media attached to items in the collection. 23 | * @param string|string[] $tags 24 | * If one or more tags are specified, only media attached to those tags will be loaded. 25 | * @param bool $matchAll If true, only load media attached to all tags simultaneously 26 | * @param bool $withVariants If true, also load the variants and/or originalMedia relation of each Media 27 | * @return $this 28 | */ 29 | public function loadMedia( 30 | $tags = [], 31 | bool $matchAll = false, 32 | bool $withVariants = false 33 | ): self { 34 | if ($this->isEmpty()) { 35 | return $this; 36 | } 37 | 38 | $tags = (array)$tags; 39 | 40 | if (empty($tags)) { 41 | if ($withVariants) { 42 | return $this->load(['media.originalMedia.variants', 'media.variants']); 43 | } else { 44 | return $this->load('media'); 45 | } 46 | } 47 | 48 | if ($matchAll) { 49 | $closure = function (MorphToMany $q) use ($tags, $withVariants) { 50 | if (method_exists($this, 'addMatchAllToEagerLoadQuery')) { 51 | $this->addMatchAllToEagerLoadQuery($q, $tags); 52 | } 53 | 54 | if ($withVariants) { 55 | $q->with(['originalMedia.variants', 'variants']); 56 | } 57 | }; 58 | $closure = Closure::bind($closure, $this->first(), $this->first()); 59 | 60 | return $this->load(['media' => $closure]); 61 | } 62 | 63 | 64 | return $this->load( 65 | [ 66 | 'media' => function (MorphToMany $q) use ($tags, $withVariants) { 67 | $q->wherePivotIn('tag', $tags); 68 | 69 | if ($withVariants) { 70 | $q->with(['originalMedia.variants', 'variants']); 71 | } 72 | } 73 | ] 74 | ); 75 | } 76 | 77 | /** 78 | * Lazy eager load media attached to items in the collection, as well as their variants. 79 | * @param string|string[] $tags 80 | * If one or more tags are specified, only media attached to those tags will be loaded. 81 | * @param bool $matchAll If true, only load media attached to all tags simultaneously 82 | * @return $this 83 | */ 84 | public function loadMediaWithVariants($tags = [], bool $matchAll = false): self 85 | { 86 | return $this->loadMedia($tags, $matchAll, true); 87 | } 88 | 89 | /** 90 | * Lazy eager load media attached to items in the collection bound all of the provided 91 | * tags simultaneously. 92 | * @param string|string[] $tags 93 | * @param bool $withVariants If true, also load the variants and/or originalMedia relation of each Media 94 | * If one or more tags are specified, only media attached to all of those tags will be loaded. 95 | * @return $this 96 | */ 97 | public function loadMediaMatchAll($tags = [], bool $withVariants = false): self 98 | { 99 | return $this->loadMedia($tags, true, $withVariants); 100 | } 101 | 102 | /** 103 | * Lazy eager load media attached to items in the collection bound all of the provided 104 | * tags simultaneously, as well as the variants of those media. 105 | * @param string|string[] $tags 106 | * If one or more tags are specified, only media attached to all of those tags will be loaded. 107 | * @return $this 108 | */ 109 | public function loadMediaWithVariantsMatchAll($tags = []): self 110 | { 111 | return $this->loadMedia($tags, true, true); 112 | } 113 | 114 | public function delete(): void 115 | { 116 | if (count($this) == 0) { 117 | return; 118 | } 119 | 120 | /** @var MorphToMany $relation */ 121 | $relation = $this->first()->media(); 122 | $query = $relation->newPivotStatement(); 123 | $classes = []; 124 | 125 | $this->each( 126 | function (Model $item) use (&$classes) { 127 | // collect list of ids of each class in case not all 128 | // items belong to the same class 129 | $classes[get_class($item)][] = $item->getKey(); 130 | } 131 | ); 132 | 133 | // delete each item by class 134 | collect($classes)->each( 135 | /** 136 | * @param array $ids 137 | * @param class-string $class 138 | */ 139 | function (array $ids, string $class) use ($query, $relation) { 140 | // select pivots matching each item for deletion 141 | $query->orWhere( 142 | function (Builder $q) use ($class, $ids, $relation) { 143 | $q->where($relation->getMorphType(), $class); 144 | $q->whereIn( 145 | $relation->getQualifiedForeignPivotKeyName(), 146 | $ids 147 | ); 148 | } 149 | ); 150 | 151 | $class::query()->whereIn((new $class)->getKeyName(), $ids)->delete(); 152 | } 153 | ); 154 | 155 | // delete pivots 156 | $query->delete(); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/MediableInterface.php: -------------------------------------------------------------------------------- 1 | $media 11 | * @method static Builder withMedia($tags = [], bool $matchAll = false, bool $withVariants = false) 12 | * @method static Builder withMediaAndVariants($tags = [], bool $matchAll = false) 13 | * @method static Builder withMediaMatchAll($tags = [], bool $withVariants = false) 14 | * @method static Builder withMediaAndVariantsMatchAll($tags = []) 15 | * @method static Builder whereHasMedia($tags = [], bool $matchAll = false) 16 | * @method static Builder whereHasMediaMatchAll($tags) 17 | */ 18 | interface MediableInterface 19 | { 20 | public function media(): MorphToMany; 21 | 22 | /** 23 | * @param Builder $q 24 | * @param string|string[] $tags 25 | * @param bool $matchAll 26 | * @return void 27 | */ 28 | public function scopeWhereHasMedia( 29 | Builder $q, 30 | $tags = [], 31 | bool $matchAll = false 32 | ): void; 33 | 34 | public function scopeWhereHasMediaMatchAll(Builder $q, array $tags): void; 35 | 36 | /** 37 | * @param Builder $q 38 | * @param string|string[] $tags 39 | * @param bool $matchAll 40 | * @param bool $withVariants 41 | * @return mixed 42 | */ 43 | public function scopeWithMedia( 44 | Builder $q, 45 | $tags = [], 46 | bool $matchAll = false, 47 | bool $withVariants = false 48 | ); 49 | 50 | /** 51 | * @param Builder $q 52 | * @param string|string[] $tags 53 | * @param bool $matchAll 54 | * @return mixed 55 | */ 56 | public function scopeWithMediaAndVariants( 57 | Builder $q, 58 | $tags = [], 59 | bool $matchAll = false 60 | ); 61 | 62 | /** 63 | * @param Builder $q 64 | * @param string|string[]$tags 65 | * @param bool $withVariants 66 | * @return mixed 67 | */ 68 | public function scopeWithMediaMatchAll( 69 | Builder $q, 70 | $tags = [], 71 | bool $withVariants = false 72 | ); 73 | 74 | /** 75 | * @param Builder $q 76 | * @param string|string[] $tags 77 | * @return void 78 | */ 79 | public function scopeWithMediaAndVariantsMatchAll(Builder $q, $tags = []): void; 80 | 81 | public function loadMedia(); 82 | 83 | /** 84 | * @param string|string[] $tags 85 | * @param bool $matchAll 86 | * @return self 87 | */ 88 | public function loadMediaWithVariants($tags = [], bool $matchAll = false): self; 89 | 90 | /** 91 | * @param string|string[] $tags 92 | * @param bool $withVariants 93 | * @return self 94 | */ 95 | public function loadMediaMatchAll($tags = [], bool $withVariants = false): self; 96 | 97 | /** 98 | * @param string|string[] $tags 99 | * @return self 100 | */ 101 | public function loadMediaWithVariantsMatchAll($tags = []): self; 102 | 103 | /** 104 | * @param string|int|int[]|Media|Collection $media 105 | * @param string|string[] $tags 106 | * @return void 107 | */ 108 | public function attachMedia($media, $tags): void; 109 | 110 | /** 111 | * @param string|int|int[]|Media|Collection $media 112 | * @param string|string[] $tags 113 | * @return void 114 | */ 115 | public function syncMedia($media, $tags): void; 116 | 117 | /** 118 | * @param string|int|int[]|Media|Collection $media 119 | * @param string|string[] $tags 120 | * @return void 121 | */ 122 | public function detachMedia($media, $tags = null): void; 123 | 124 | /** 125 | * @param string|string[] $tags 126 | * @return void 127 | */ 128 | public function detachMediaTags($tags): void; 129 | 130 | /** 131 | * @param string|string[] $tags 132 | * @param bool $matchAll 133 | * @return bool 134 | */ 135 | public function hasMedia($tags, bool $matchAll = false): bool; 136 | 137 | /** 138 | * @param string|string[] $tags 139 | * @param bool $matchAll 140 | * @return Collection 141 | */ 142 | public function getMedia($tags, bool $matchAll = false): Collection; 143 | 144 | public function getMediaMatchAll(array $tags): Collection; 145 | 146 | /** 147 | * @param string|string[] $tags 148 | * @param bool $matchAll 149 | * @return Media|null 150 | */ 151 | public function firstMedia($tags, bool $matchAll = false): ?Media; 152 | 153 | /** 154 | * @param string|string[] $tags 155 | * @param bool $matchAll 156 | * @return Media|null 157 | */ 158 | public function lastMedia($tags, bool $matchAll = false): ?Media; 159 | 160 | public function getAllMediaByTag(): Collection; 161 | 162 | public function getTagsForMedia(Media $media): array; 163 | 164 | /** 165 | * @param array|string $relations 166 | * @return mixed 167 | */ 168 | public function load($relations); 169 | 170 | /** 171 | * @param array $models 172 | * @return MediableCollection 173 | */ 174 | public function newCollection(array $models = []); 175 | } 176 | -------------------------------------------------------------------------------- /src/MediableServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes( 33 | [ 34 | $root . '/config/mediable.php' => config_path('mediable.php'), 35 | ], 36 | 'config' 37 | ); 38 | 39 | $time = time(); 40 | 41 | if (empty(glob($this->app->databasePath('migrations/*_create_mediable_tables.php')))) { 42 | $this->publishes( 43 | [ 44 | $root . '/migrations/2016_06_27_000000_create_mediable_tables.php' => 45 | $this->app->databasePath( 46 | 'migrations/' . date( 47 | 'Y_m_d_His', 48 | $time 49 | ) . '_create_mediable_tables.php' 50 | ) 51 | ], 52 | 'mediable-migrations' 53 | ); 54 | $time++; 55 | } 56 | if (empty(glob($this->app->databasePath('migrations/*_add_variants_to_media.php')))) { 57 | $this->publishes( 58 | [ 59 | $root . '/migrations/2020_10_12_000000_add_variants_to_media.php' => 60 | $this->app->databasePath( 61 | 'migrations/' . date( 62 | 'Y_m_d_His', 63 | $time 64 | ) . '_add_variants_to_media.php' 65 | ) 66 | ], 67 | 'mediable-migrations' 68 | ); 69 | $time++; 70 | } 71 | 72 | if (empty(glob($this->app->databasePath('migrations/*_add_alt_to_media.php')))) { 73 | $this->publishes( 74 | [ 75 | $root . '/migrations/2024_03_30_000000_add_alt_to_media.php' => 76 | $this->app->databasePath( 77 | 'migrations/' . date( 78 | 'Y_m_d_His', 79 | $time 80 | ) . '_add_alt_to_media.php' 81 | ), 82 | ], 83 | 'mediable-migrations' 84 | ); 85 | } 86 | 87 | if (!config('mediable.ignore_migrations', false)) { 88 | $this->loadMigrationsFrom($root . '/migrations'); 89 | } 90 | } 91 | 92 | /** 93 | * Register the service provider. 94 | * 95 | * @return void 96 | */ 97 | public function register(): void 98 | { 99 | $this->mergeConfigFrom( 100 | dirname(__DIR__) . '/config/mediable.php', 101 | 'mediable' 102 | ); 103 | 104 | $this->registerSourceAdapterFactory(); 105 | $this->registerImageManipulator(); 106 | $this->registerUploader(); 107 | $this->registerMover(); 108 | $this->registerUrlGeneratorFactory(); 109 | $this->registerConsoleCommands(); 110 | } 111 | 112 | /** 113 | * Bind an instance of the Source Adapter Factory to the container. 114 | * 115 | * Attaches the default adapter types 116 | * @return void 117 | */ 118 | public function registerSourceAdapterFactory(): void 119 | { 120 | $this->app->singleton('mediable.source.factory', function (Container $app) { 121 | $factory = new SourceAdapterFactory; 122 | 123 | $classAdapters = $app['config']->get('mediable.source_adapters.class', []); 124 | foreach ($classAdapters as $source => $adapter) { 125 | $factory->setAdapterForClass($adapter, $source); 126 | } 127 | 128 | $patternAdapters = $app['config']->get('mediable.source_adapters.pattern', []); 129 | foreach ($patternAdapters as $source => $adapter) { 130 | $factory->setAdapterForPattern($adapter, $source); 131 | } 132 | 133 | return $factory; 134 | }); 135 | $this->app->alias('mediable.source.factory', SourceAdapterFactory::class); 136 | } 137 | 138 | /** 139 | * Bind the Media Uploader to the container. 140 | * @return void 141 | */ 142 | public function registerUploader(): void 143 | { 144 | $this->app->bind('mediable.uploader', function (Container $app) { 145 | return new MediaUploader( 146 | $app['filesystem'], 147 | $app['mediable.source.factory'], 148 | $app[ImageManipulator::class], 149 | $app['config']->get('mediable') 150 | ); 151 | }); 152 | $this->app->alias('mediable.uploader', MediaUploader::class); 153 | } 154 | 155 | /** 156 | * Bind the Media Uploader to the container. 157 | * @return void 158 | */ 159 | public function registerMover(): void 160 | { 161 | $this->app->bind('mediable.mover', function (Container $app) { 162 | return new MediaMover($app['filesystem']); 163 | }); 164 | $this->app->alias('mediable.mover', MediaMover::class); 165 | } 166 | 167 | /** 168 | * Bind the Media Uploader to the container. 169 | * @return void 170 | */ 171 | public function registerUrlGeneratorFactory(): void 172 | { 173 | $this->app->singleton('mediable.url.factory', function (Container $app) { 174 | $factory = new UrlGeneratorFactory; 175 | 176 | $config = $app['config']->get('mediable.url_generators', []); 177 | foreach ($config as $driver => $generator) { 178 | $factory->setGeneratorForFilesystemDriver($generator, $driver); 179 | } 180 | 181 | return $factory; 182 | }); 183 | $this->app->alias('mediable.url.factory', UrlGeneratorFactory::class); 184 | } 185 | 186 | public function registerImageManipulator(): void 187 | { 188 | $this->app->singleton(ImageManipulator::class, function (Container $app) { 189 | return new ImageManipulator( 190 | $this->getInterventionImageManagerConfiguration($app), 191 | $app->get(FilesystemManager::class), 192 | $app->get(ImageOptimizer::class) 193 | ); 194 | }); 195 | } 196 | 197 | /** 198 | * Add package commands to artisan console. 199 | * @return void 200 | */ 201 | public function registerConsoleCommands(): void 202 | { 203 | $this->commands([ 204 | ImportMediaCommand::class, 205 | PruneMediaCommand::class, 206 | SyncMediaCommand::class, 207 | ]); 208 | } 209 | 210 | private function getInterventionImageManagerConfiguration(Container $app): ?ImageManager 211 | { 212 | $imageManager = null; 213 | if ($app->has(ImageManager::class) 214 | || ( 215 | class_exists(DriverInterface::class) // intervention >= 3.0 216 | && $app->has(DriverInterface::class) 217 | ) 218 | ) { 219 | // use whatever the user has bound to the container if available 220 | $imageManager = $app->get(ImageManager::class); 221 | } elseif (extension_loaded('imagick')) { 222 | // attempt to automatically configure for imagick 223 | if (class_exists(\Intervention\Image\Drivers\Imagick\Driver::class)) { 224 | // intervention/image >=3.0 225 | $imageManager = new ImageManager( 226 | new \Intervention\Image\Drivers\Imagick\Driver() 227 | ); 228 | } else { 229 | // intervention/image <3.0 230 | $imageManager = new ImageManager(['driver' => 'imagick']); 231 | } 232 | } elseif (extension_loaded('gd')) { 233 | // attempt to automatically configure for gd 234 | if (class_exists(\Intervention\Image\Drivers\Gd\Driver::class)) { 235 | // intervention/image >=3.0 236 | $imageManager = new ImageManager( 237 | new \Intervention\Image\Drivers\Gd\Driver() 238 | ); 239 | } else { 240 | // intervention/image <3.0 241 | $imageManager = new ImageManager(['driver' => 'gd']); 242 | } 243 | } 244 | 245 | return $imageManager; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/SourceAdapters/DataUrlAdapter.php: -------------------------------------------------------------------------------- 1 | file = $source; 22 | $path = $source->getRealPath(); 23 | if ($path === false) { 24 | throw ConfigurationException::invalidSource( 25 | "File not found {$source->getPathname()}" 26 | ); 27 | } 28 | parent::__construct( 29 | Utils::streamFor( 30 | Utils::tryFopen($path, 'rb') 31 | ) 32 | ); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function filename(): ?string 39 | { 40 | return pathinfo($this->file->getRealPath(), PATHINFO_FILENAME) ?: null; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function extension(): ?string 47 | { 48 | return pathinfo($this->file->getRealPath(), PATHINFO_EXTENSION) ?: null; 49 | } 50 | 51 | public function clientMimeType(): ?string 52 | { 53 | return null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/SourceAdapters/LocalPathAdapter.php: -------------------------------------------------------------------------------- 1 | filePath = $source; 24 | if (!is_file($source) || !is_readable($source)) { 25 | throw ConfigurationException::invalidSource( 26 | "File not found {$source}" 27 | ); 28 | } 29 | parent::__construct( 30 | Utils::streamFor(Utils::tryFopen($source, 'rb')) 31 | ); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function path(): ?string 38 | { 39 | return $this->filePath; 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function filename(): ?string 46 | { 47 | return pathinfo($this->filePath, PATHINFO_FILENAME) ?: null; 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function extension(): ?string 54 | { 55 | return pathinfo($this->filePath, PATHINFO_EXTENSION) ?: null; 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | public function mimeType(): string 62 | { 63 | return mime_content_type($this->filePath); 64 | } 65 | 66 | public function clientMimeType(): ?string 67 | { 68 | return null; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/SourceAdapters/RawContentAdapter.php: -------------------------------------------------------------------------------- 1 | source = $source; 21 | } 22 | 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function path(): ?string 27 | { 28 | return null; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function filename(): ?string 35 | { 36 | return null; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function extension(): ?string 43 | { 44 | return null; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function mimeType(): string 51 | { 52 | $fileInfo = new \finfo(FILEINFO_MIME_TYPE); 53 | 54 | return (string)$fileInfo->buffer($this->source); 55 | } 56 | 57 | public function clientMimeType(): ?string 58 | { 59 | return null; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function getStream(): StreamInterface 66 | { 67 | return Utils::streamFor($this->source); 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function size(): int 74 | { 75 | return mb_strlen($this->source, '8bit') ?: 0; 76 | } 77 | 78 | public function hash(string $algo = 'md5'): string 79 | { 80 | $hash = hash_init($algo); 81 | hash_update($hash, $this->source); 82 | return hash_final($hash); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/SourceAdapters/RemoteUrlAdapter.php: -------------------------------------------------------------------------------- 1 | url = $source; 21 | try { 22 | $resource = Utils::tryFopen($source, 'rb'); 23 | $stream = Utils::streamFor($resource); 24 | } catch (\RuntimeException $e) { 25 | throw ConfigurationException::invalidSource( 26 | "Failed to connect to URL: {$e->getMessage()}", 27 | $e 28 | ); 29 | } 30 | parent::__construct( 31 | $stream 32 | ); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function path(): ?string 39 | { 40 | return $this->url; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function filename(): ?string 47 | { 48 | return pathinfo( 49 | parse_url($this->url, PHP_URL_PATH), 50 | PATHINFO_FILENAME 51 | ) ?: null; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function extension(): ?string 58 | { 59 | return pathinfo( 60 | parse_url($this->url, PHP_URL_PATH), 61 | PATHINFO_EXTENSION 62 | ) ?: null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/SourceAdapters/SourceAdapterFactory.php: -------------------------------------------------------------------------------- 1 | [] 18 | */ 19 | private array $classAdapters = []; 20 | 21 | /** 22 | * Map of which adapters to use for a given string pattern. 23 | * @var class-string[] 24 | */ 25 | private array $patternAdapters = []; 26 | 27 | /** 28 | * Create a Source Adapter for the provided source. 29 | * @param object|string|resource $source 30 | * @return SourceAdapterInterface 31 | * @throws ConfigurationException If the provided source does not match any of the mapped classes or patterns 32 | */ 33 | public function create($source): SourceAdapterInterface 34 | { 35 | $adapter = null; 36 | 37 | if ($source instanceof SourceAdapterInterface) { 38 | return $source; 39 | } elseif (is_object($source)) { 40 | $adapter = $this->adaptClass($source); 41 | } elseif (is_resource($source)) { 42 | $adapter = StreamResourceAdapter::class; 43 | } elseif (is_string($source)) { 44 | $adapter = $this->adaptString($source); 45 | } 46 | 47 | if ($adapter) { 48 | return new $adapter($source); 49 | } 50 | 51 | throw ConfigurationException::unrecognizedSource($source); 52 | } 53 | 54 | /** 55 | * Specify the FQCN of a SourceAdapter class to use when the source inherits from a given class. 56 | * @param class-string $adapterClass 57 | * @param string $sourceClass 58 | * @return void 59 | * 60 | * @throws ConfigurationException 61 | */ 62 | public function setAdapterForClass(string $adapterClass, string $sourceClass): void 63 | { 64 | $this->validateAdapterClass($adapterClass); 65 | $this->classAdapters[$sourceClass] = $adapterClass; 66 | } 67 | 68 | /** 69 | * Specify the FQCN of a SourceAdapter class to use when the source is a string matching the given pattern. 70 | * @param class-string $adapterClass 71 | * @param string $sourcePattern 72 | * @return void 73 | * 74 | * @throws ConfigurationException 75 | */ 76 | public function setAdapterForPattern(string $adapterClass, string $sourcePattern): void 77 | { 78 | $this->validateAdapterClass($adapterClass); 79 | $this->patternAdapters[$sourcePattern] = $adapterClass; 80 | } 81 | 82 | /** 83 | * Choose an adapter class for the class of the provided object. 84 | * @param object $source 85 | * @return class-string|null 86 | */ 87 | private function adaptClass(object $source): ?string 88 | { 89 | foreach ($this->classAdapters as $class => $adapter) { 90 | if ($source instanceof $class) { 91 | return $adapter; 92 | } 93 | } 94 | 95 | return null; 96 | } 97 | 98 | /** 99 | * Choose an adapter class for the provided string. 100 | * @param string $source 101 | * @return class-string|null 102 | */ 103 | private function adaptString(string $source): ?string 104 | { 105 | foreach ($this->patternAdapters as $pattern => $adapter) { 106 | $pattern = '/' . str_replace('/', '\\/', $pattern) . '/i'; 107 | if (preg_match($pattern, $source)) { 108 | return $adapter; 109 | } 110 | } 111 | 112 | return null; 113 | } 114 | 115 | /** 116 | * Verify that the provided class implements the SourceAdapter interface. 117 | * @param class-string $class 118 | * @throws ConfigurationException If class is not valid 119 | * @return void 120 | */ 121 | private function validateAdapterClass(string $class): void 122 | { 123 | if (!is_a($class, SourceAdapterInterface::class, true)) { 124 | throw ConfigurationException::cannotSetAdapter($class); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/SourceAdapters/SourceAdapterInterface.php: -------------------------------------------------------------------------------- 1 | */ 38 | protected array $hash; 39 | 40 | protected string $mimeType; 41 | 42 | /** 43 | * Constructor. 44 | * @param StreamInterface $source 45 | */ 46 | public function __construct(StreamInterface $source) 47 | { 48 | if (!$source->isReadable()) { 49 | throw ConfigurationException::invalidSource('Stream must be readable'); 50 | } 51 | 52 | $this->source = $this->originalSource = $source; 53 | if (!$this->source->isSeekable()) { 54 | $this->source = new CachingStream($this->source); 55 | } 56 | 57 | if ($this->getStreamType() === self::TYPE_HTTP) { 58 | $code = $this->getHttpResponseCode(); 59 | if (!$code || $code < 200 || $code >= 300) { 60 | throw ConfigurationException::unrecognizedSource( 61 | "Failed to fetch URL, received HTTP status code $code" 62 | ); 63 | } 64 | } 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | private function path(): ?string 71 | { 72 | $type = $this->getStreamType(); 73 | if (in_array($type, [self::TYPE_DATA_URL, self::TYPE_MEMORY])) { 74 | return null; 75 | } 76 | 77 | return $this->originalSource->getMetadata('uri'); 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | public function filename(): ?string 84 | { 85 | $path = $this->path(); 86 | if (!$path) { 87 | return null; 88 | } 89 | return pathinfo( 90 | parse_url($this->path(), PHP_URL_PATH) ?? '', 91 | PATHINFO_FILENAME 92 | ) ?: null; 93 | } 94 | 95 | /** 96 | * {@inheritdoc} 97 | */ 98 | public function extension(): ?string 99 | { 100 | if ($path = $this->path()) { 101 | $extension = pathinfo( 102 | parse_url($path, PHP_URL_PATH) ?? '', 103 | PATHINFO_EXTENSION 104 | ); 105 | if ($extension) { 106 | return $extension; 107 | } 108 | } 109 | 110 | return null; 111 | } 112 | 113 | /** 114 | * {@inheritdoc} 115 | */ 116 | public function mimeType(): string 117 | { 118 | if (!isset($this->mimeType)) { 119 | $this->scanFile(); 120 | } 121 | 122 | return $this->mimeType; 123 | } 124 | 125 | public function clientMimeType(): ?string 126 | { 127 | // supported primarily by data URLs 128 | if ($mime = $this->originalSource->getMetadata('mediatype')) { 129 | return $mime; 130 | } 131 | 132 | if ($contentType = $this->getHttpHeader('Content-Type')) { 133 | $mime = explode(';', $contentType)[0]; 134 | 135 | return $mime; 136 | } 137 | 138 | return null; 139 | } 140 | 141 | public function getStream(): StreamInterface 142 | { 143 | return $this->source; 144 | } 145 | 146 | /** 147 | * {@inheritdoc} 148 | */ 149 | public function size(): int 150 | { 151 | $size = $this->source->getSize(); 152 | 153 | if (!is_null($size)) { 154 | return $size; 155 | } 156 | 157 | if (!isset($this->size)) { 158 | $this->scanFile(); 159 | } 160 | 161 | return $this->size; 162 | } 163 | 164 | /** 165 | * {@inheritdoc} 166 | * @param string $algo 167 | */ 168 | public function hash(string $algo = 'md5'): string 169 | { 170 | if (!isset($this->hash[$algo])) { 171 | $this->scanFile($algo); 172 | } 173 | return $this->hash[$algo]; 174 | } 175 | 176 | /** 177 | * @return array|mixed|null 178 | */ 179 | private function getStreamType(): mixed 180 | { 181 | return strtolower($this->originalSource->getMetadata('wrapper_type')); 182 | } 183 | 184 | private function getHttpHeader($headerName): ?string 185 | { 186 | if ($this->getStreamType() !== self::TYPE_HTTP) { 187 | return null; 188 | } 189 | 190 | $headers = $this->originalSource->getMetadata('wrapper_data'); 191 | if ($headers) { 192 | foreach ($headers as $header) { 193 | if (stripos($header, "$headerName: ") === 0) { 194 | return substr($header, strlen($headerName) + 2); 195 | } 196 | } 197 | } 198 | 199 | return null; 200 | } 201 | 202 | private function getHttpResponseCode(): ?int 203 | { 204 | if ($this->getStreamType() !== self::TYPE_HTTP) { 205 | return null; 206 | } 207 | $headers = $this->originalSource->getMetadata('wrapper_data'); 208 | if (!empty($headers) 209 | && preg_match('/HTTP\/\d+\.\d+\s+(\d+)/i', $headers[0], $matches) 210 | ) { 211 | return (int)$matches[1]; 212 | } 213 | 214 | return null; 215 | } 216 | 217 | private function scanFile(string $hashAlgorithm = 'md5'): void 218 | { 219 | $this->size = 0; 220 | $this->source->rewind(); 221 | try { 222 | $hash = hash_init($hashAlgorithm); 223 | $finfo = finfo_open(FILEINFO_MIME_TYPE); 224 | while (!$this->source->eof()) { 225 | $buffer = $this->source->read(self::BUFFER_SIZE); 226 | if (!isset($this->mimeType)) { 227 | $this->mimeType = finfo_buffer($finfo, $buffer); 228 | } 229 | hash_update($hash, $buffer); 230 | $this->size += strlen($buffer); 231 | } 232 | $this->hash[$hashAlgorithm] = hash_final($hash); 233 | $this->source->rewind(); 234 | } finally { 235 | if (!empty($finfo)) { 236 | finfo_close($finfo); 237 | } 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/SourceAdapters/StreamResourceAdapter.php: -------------------------------------------------------------------------------- 1 | resource = $source; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/SourceAdapters/UploadedFileAdapter.php: -------------------------------------------------------------------------------- 1 | isValid()) { 27 | throw ConfigurationException::invalidSource( 28 | "Uploaded file is not valid: {$source->getErrorMessage()}" 29 | ); 30 | } 31 | $this->uploadedFile = $source; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function filename(): ?string 38 | { 39 | return pathinfo($this->uploadedFile->getClientOriginalName(), PATHINFO_FILENAME) ?: null; 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function extension(): ?string 46 | { 47 | return $this->uploadedFile->getClientOriginalExtension() ?: null; 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function mimeType(): string 54 | { 55 | return $this->uploadedFile->getMimeType(); 56 | } 57 | 58 | public function clientMimeType(): ?string 59 | { 60 | return $this->uploadedFile->getClientMimeType(); 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | public function getStream(): StreamInterface 67 | { 68 | return Utils::streamFor(fopen($this->uploadedFile->getRealPath(), 'rb')); 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function size(): int 75 | { 76 | return $this->uploadedFile->getSize() ?: 0; 77 | } 78 | 79 | /** 80 | * {@inheritdoc} 81 | * @param string $algo 82 | */ 83 | public function hash(string $algo = 'md5'): string 84 | { 85 | return hash_file($algo, $this->uploadedFile->getRealPath()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/UrlGenerators/BaseUrlGenerator.php: -------------------------------------------------------------------------------- 1 | config = $config; 25 | } 26 | 27 | /** 28 | * Set the media being operated on. 29 | * @param \Plank\Mediable\Media $media 30 | */ 31 | public function setMedia(Media $media): void 32 | { 33 | $this->media = $media; 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function isPubliclyAccessible(): bool 40 | { 41 | return $this->getDiskConfig('visibility', 'private') == 'public' && $this->media->isVisible(); 42 | } 43 | 44 | /** 45 | * Get a config value for the current disk. 46 | * @param string $key 47 | * @param mixed $default 48 | * @return mixed 49 | */ 50 | protected function getDiskConfig(string $key, $default = null): mixed 51 | { 52 | return $this->config->get("filesystems.disks.{$this->media->disk}.{$key}", $default); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/UrlGenerators/LocalUrlGenerator.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function isPubliclyAccessible(): bool 29 | { 30 | return ($this->getDiskConfig('visibility', 'private') == 'public' || $this->isInWebroot()) 31 | && $this->media->isVisible(); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | * @throws \Plank\Mediable\Exceptions\MediaUrlException If media's disk is not publicly accessible 37 | */ 38 | public function getUrl(): string 39 | { 40 | /** @var Cloud $filesystem */ 41 | $filesystem = $this->filesystem->disk($this->media->disk); 42 | return $filesystem->url($this->media->getDiskPath()); 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function getAbsolutePath(): string 49 | { 50 | return $this->getDiskConfig('root') . DIRECTORY_SEPARATOR . $this->media->getDiskPath(); 51 | } 52 | 53 | private function isInWebroot(): bool 54 | { 55 | return strpos($this->getAbsolutePath(), public_path()) === 0; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/UrlGenerators/S3UrlGenerator.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function getAbsolutePath(): string 33 | { 34 | return $this->getUrl(); 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function getUrl(): string 41 | { 42 | /** @var Cloud $filesystem */ 43 | $filesystem = $this->filesystem->disk($this->media->disk); 44 | return $filesystem->url($this->media->getDiskPath()); 45 | } 46 | 47 | public function getTemporaryUrl(\DateTimeInterface $expiry): string 48 | { 49 | $filesystem = $this->filesystem->disk($this->media->disk); 50 | return $filesystem->temporaryUrl($this->media->getDiskPath(), $expiry); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/UrlGenerators/TemporaryUrlGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | getDriverForDisk($media->disk); 26 | if (array_key_exists($driver, $this->driver_generators)) { 27 | $class = $this->driver_generators[$driver]; 28 | 29 | $generator = app($class); 30 | $generator->setMedia($media); 31 | 32 | return $generator; 33 | } 34 | 35 | throw MediaUrlException::generatorNotFound($media->disk, $driver); 36 | } 37 | 38 | /** 39 | * Set a generator subclass to use for media on a disk with a particular driver. 40 | * @param string $class 41 | * @param string $driver 42 | * @return void 43 | * 44 | * @throws MediaUrlException 45 | */ 46 | public function setGeneratorForFilesystemDriver(string $class, string $driver): void 47 | { 48 | $this->validateGeneratorClass($class); 49 | $this->driver_generators[$driver] = $class; 50 | } 51 | 52 | /** 53 | * Verify that a class name is a valid generator. 54 | * @param string $class 55 | * @return void 56 | * 57 | * @throws MediaUrlException If class does not exist or does not implement `UrlGenerator` 58 | */ 59 | protected function validateGeneratorClass(string $class): void 60 | { 61 | if (!class_exists($class) || !is_subclass_of($class, UrlGeneratorInterface::class)) { 62 | throw MediaUrlException::invalidGenerator($class); 63 | } 64 | } 65 | 66 | /** 67 | * Get the driver used by a specified disk. 68 | * @param string $disk 69 | * @return string 70 | */ 71 | protected function getDriverForDisk(string $disk): string 72 | { 73 | $driver = (string) config("filesystems.disks.{$disk}.driver"); 74 | if ($driver === 'scoped') { 75 | return $this->getDriverForDisk(config("filesystems.disks.{$disk}.disk")); 76 | } 77 | return $driver; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/UrlGenerators/UrlGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | define(Plank\Mediable\Media::class, function (Faker\Generator $faker) { 9 | $types = config('mediable.aggregate_types'); 10 | $type = $faker->randomElement(array_keys($types)); 11 | 12 | return [ 13 | 'disk' => 'tmp', 14 | 'directory' => implode('/', $faker->words($faker->randomDigit())), 15 | 'filename' => $faker->word, 16 | 'extension' => $faker->randomElement($types[$type]['extensions']), 17 | 'mime_type' => $faker->randomElement($types[$type]['mime_types']), 18 | 'aggregate_type' => $type, 19 | 'size' => $faker->randomNumber(), 20 | 'alt' => $faker->sentence, 21 | ]; 22 | }); 23 | 24 | $factory->define(MediaSoftDelete::class, function (Faker\Generator $faker) { 25 | $types = config('mediable.aggregate_types'); 26 | $type = $faker->randomElement(array_keys($types)); 27 | 28 | return [ 29 | 'disk' => 'tmp', 30 | 'directory' => implode('/', $faker->words($faker->randomDigit())), 31 | 'filename' => $faker->word, 32 | 'extension' => $faker->randomElement($types[$type]['extensions']), 33 | 'mime_type' => $faker->randomElement($types[$type]['mime_types']), 34 | 'aggregate_type' => $type, 35 | 'size' => $faker->randomNumber(), 36 | ]; 37 | }); 38 | 39 | $factory->define(SampleMediable::class, function (Faker\Generator $faker) { 40 | return []; 41 | }); 42 | 43 | $factory->define(SampleMediableSoftDelete::class, function (Faker\Generator $faker) { 44 | return []; 45 | }); 46 | -------------------------------------------------------------------------------- /tests/Integration/Commands/ImportMediaCommandTest.php: -------------------------------------------------------------------------------- 1 | useDatabase(); 17 | $this->useFilesystem('tmp'); 18 | $this->withoutMockingConsoleOutput(); 19 | } 20 | 21 | public function getEnvironmentSetUp($app) 22 | { 23 | parent::getEnvironmentSetUp($app); 24 | $app['config']->set('mediable.allow_unrecognized_types', true); 25 | $app['config']->set('mediable.strict_type_checking', false); 26 | } 27 | 28 | public function test_it_creates_media_for_unmatched_files(): void 29 | { 30 | $artisan = $this->getArtisan(); 31 | $media1 = factory(Media::class)->make(['disk' => 'tmp', 'filename' => 'foo']); 32 | $media2 = factory(Media::class)->create(['disk' => 'tmp', 'filename' => 'bar']); 33 | $this->seedFileForMedia($media1); 34 | $this->seedFileForMedia($media2); 35 | 36 | $artisan->call('media:import', ['disk' => 'tmp']); 37 | 38 | $this->assertEquals("Imported 1 file(s).\n", $artisan->output()); 39 | $this->assertEquals( 40 | ['bar', 'foo'], 41 | Media::query()->orderBy('filename')->pluck('filename')->toArray() 42 | ); 43 | } 44 | 45 | public function test_it_creates_media_for_unmatched_files_in_directory(): void 46 | { 47 | $artisan = $this->getArtisan(); 48 | $media1 = factory(Media::class)->make( 49 | ['disk' => 'tmp', 'directory' => 'a', 'filename' => 'foo'] 50 | ); 51 | $media2 = factory(Media::class)->make( 52 | ['disk' => 'tmp', 'directory' => 'a/b', 'filename' => 'bar'] 53 | ); 54 | $this->seedFileForMedia($media1); 55 | $this->seedFileForMedia($media2); 56 | 57 | $artisan->call('media:import', ['disk' => 'tmp', '--directory' => 'a/b']); 58 | 59 | $this->assertEquals("Imported 1 file(s).\n", $artisan->output()); 60 | $this->assertEquals(['bar'], Media::query()->pluck('filename')->toArray()); 61 | } 62 | 63 | public function test_it_creates_media_for_unmatched_files_non_recursively(): void 64 | { 65 | $artisan = $this->getArtisan(); 66 | $media1 = factory(Media::class)->make( 67 | ['disk' => 'tmp', 'directory' => 'a', 'filename' => 'foo'] 68 | ); 69 | $media2 = factory(Media::class)->make( 70 | ['disk' => 'tmp', 'directory' => 'a/b', 'filename' => 'bar'] 71 | ); 72 | $this->seedFileForMedia($media1); 73 | $this->seedFileForMedia($media2); 74 | 75 | $artisan->call( 76 | 'media:import', 77 | ['disk' => 'tmp', '--directory' => 'a', '--non-recursive' => true] 78 | ); 79 | 80 | $this->assertEquals("Imported 1 file(s).\n", $artisan->output()); 81 | $this->assertEquals(['foo'], Media::query()->pluck('filename')->toArray()); 82 | } 83 | 84 | public function test_it_skips_files_of_unmatched_aggregate_type(): void 85 | { 86 | $artisan = $this->getArtisan(); 87 | $filesystem = app(FilesystemManager::class); 88 | /** @var \Plank\Mediable\MediaUploader $uploader */ 89 | $uploader = app('mediable.uploader'); 90 | $uploader->setAllowUnrecognizedTypes(false); 91 | $uploader->setAllowedAggregateTypes(['image']); 92 | $command = new ImportMediaCommand($filesystem, $uploader); 93 | 94 | $media = factory(Media::class)->make( 95 | ['disk' => 'tmp', 'extension' => 'foo', 'mime_type' => 'bar'] 96 | ); 97 | $this->seedFileForMedia($media, 'foo'); 98 | 99 | $artisan->registerCommand($command); 100 | 101 | $artisan->call('media:import', ['disk' => 'tmp']); 102 | $this->assertEquals( 103 | "Imported 0 file(s).\nSkipped 1 file(s).\n", 104 | $artisan->output() 105 | ); 106 | } 107 | 108 | public function test_it_updates_existing_media(): void 109 | { 110 | $artisan = $this->getArtisan(); 111 | $media1 = factory(Media::class)->create( 112 | [ 113 | 'disk' => 'tmp', 114 | 'filename' => 'foo', 115 | 'extension' => 'png', 116 | 'mime_type' => 'image/png', 117 | 'aggregate_type' => 'foo' 118 | ] 119 | ); 120 | $media2 = factory(Media::class)->create( 121 | [ 122 | 'disk' => 'tmp', 123 | 'filename' => 'bar', 124 | 'extension' => 'png', 125 | 'size' => 7173, 126 | 'mime_type' => 'image/png', 127 | 'aggregate_type' => 'image' 128 | ] 129 | ); 130 | $this->seedFileForMedia($media1, $this->sampleFile()); 131 | $this->seedFileForMedia($media2, $this->sampleFile()); 132 | 133 | $artisan->call('media:import', ['disk' => 'tmp', '--force' => true]); 134 | $this->assertEquals( 135 | ['image', 'image'], 136 | Media::query()->pluck('aggregate_type')->toArray() 137 | ); 138 | $this->assertEquals( 139 | "Imported 0 file(s).\nUpdated 1 record(s).\nSkipped 1 file(s).\n", 140 | $artisan->output() 141 | ); 142 | } 143 | 144 | protected function getArtisan(): Artisan 145 | { 146 | return app(Artisan::class); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/Integration/Commands/PruneMediaCommandTest.php: -------------------------------------------------------------------------------- 1 | useDatabase(); 15 | $this->useFilesystem('tmp'); 16 | $this->withoutMockingConsoleOutput(); 17 | } 18 | 19 | public function test_it_deletes_media_without_files(): void 20 | { 21 | $artisan = $this->getArtisan(); 22 | $media1 = factory(Media::class)->create(['id' => 1, 'disk' => 'tmp']); 23 | $media2 = factory(Media::class)->create(['id' => 2, 'disk' => 'tmp']); 24 | $this->seedFileForMedia($media2); 25 | 26 | $artisan->call('media:prune', ['disk' => 'tmp']); 27 | 28 | $this->assertEquals([2], Media::query()->pluck('id')->toArray()); 29 | $this->assertEquals("Pruned 1 record(s).\n", $artisan->output()); 30 | } 31 | 32 | public function test_it_prunes_directory(): void 33 | { 34 | $artisan = $this->getArtisan(); 35 | $media1 = factory(Media::class)->create( 36 | ['id' => 1, 'disk' => 'tmp', 'directory' => ''] 37 | ); 38 | $media2 = factory(Media::class)->create( 39 | ['id' => 2, 'disk' => 'tmp', 'directory' => 'foo'] 40 | ); 41 | 42 | $artisan->call('media:prune', ['disk' => 'tmp', '--directory' => 'foo']); 43 | 44 | $this->assertEquals([1], Media::query()->pluck('id')->toArray()); 45 | $this->assertEquals("Pruned 1 record(s).\n", $artisan->output()); 46 | } 47 | 48 | public function test_it_prunes_non_recursively(): void 49 | { 50 | $artisan = $this->getArtisan(); 51 | $media1 = factory(Media::class)->create( 52 | ['id' => 1, 'disk' => 'tmp', 'directory' => ''] 53 | ); 54 | $media2 = factory(Media::class)->create( 55 | ['id' => 2, 'disk' => 'tmp', 'directory' => 'foo'] 56 | ); 57 | 58 | $artisan->call('media:prune', ['disk' => 'tmp', '--non-recursive' => true]); 59 | 60 | $this->assertEquals([2], Media::query()->pluck('id')->toArray()); 61 | $this->assertEquals("Pruned 1 record(s).\n", $artisan->output()); 62 | } 63 | 64 | public function getArtisan(): Artisan 65 | { 66 | return app(Artisan::class); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Integration/Commands/SyncMediaCommandTest.php: -------------------------------------------------------------------------------- 1 | withoutMockingConsoleOutput(); 14 | /** @var SyncMediaCommand|MockObject $command */ 15 | $command = $this->getMockBuilder(SyncMediaCommand::class) 16 | ->onlyMethods(['call', 'option', 'argument']) 17 | ->getMock(); 18 | $command->expects($this->exactly(2)) 19 | ->method('call') 20 | ->with(...$this->withConsecutive( 21 | [ 22 | $this->equalTo('media:prune'), 23 | [ 24 | 'disk' => null, 25 | '--directory' => '', 26 | '--non-recursive' => false, 27 | ] 28 | ], 29 | [ 30 | $this->equalTo('media:import'), 31 | [ 32 | 'disk' => null, 33 | '--directory' => '', 34 | '--non-recursive' => false, 35 | '--force' => false 36 | ] 37 | ] 38 | )); 39 | 40 | $command->handle(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Integration/ConnectionTest.php: -------------------------------------------------------------------------------- 1 | setupConnection(); 16 | $this->useDatabase(); 17 | } 18 | 19 | public function test_it_can_use_different_connection(): void 20 | { 21 | $media = factory(Media::class)->create(['id' => 1]); 22 | $mediable = factory(SampleMediable::class)->create(); 23 | $mediable->attachMedia($media, 'foo'); 24 | 25 | $this->assertEquals('my_connection', $media->getConnectionName()); 26 | $this->assertDatabaseHas($media->getTable(), ['id' => 1], 'my_connection'); 27 | $this->assertDatabaseHas(config('mediable.mediables_table'), [ 28 | 'media_id' => $media->getKey(), 29 | 'mediable_type' => get_class($mediable), 30 | 'mediable_id' => $mediable->getKey(), 31 | ], 'my_connection'); 32 | $this->assertEquals(1, $mediable->firstMedia('foo')->id); 33 | } 34 | 35 | protected function setupConnection(): void 36 | { 37 | $this->app['config']->set('database.connections.my_connection', [ 38 | 'driver' => 'sqlite', 39 | 'database' => ':memory:', 40 | 'prefix' => 'my__', 41 | ]); 42 | 43 | $this->app['config']->set('mediable.connection_name', 'my_connection'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Integration/HandlesMediaExceptionsTest.php: -------------------------------------------------------------------------------- 1 | render( 21 | ForbiddenException::diskNotAllowed('foo') 22 | ); 23 | 24 | $this->assertHttpException($e, 403); 25 | } 26 | 27 | public function test_it_returns_a_404_for_missing_file(): void 28 | { 29 | $e = (new SampleExceptionHandler())->render( 30 | FileNotFoundException::fileNotFound('non/existing.jpg') 31 | ); 32 | 33 | $this->assertHttpException($e, 404); 34 | } 35 | 36 | public function test_it_returns_a_409_on_duplicate_file(): void 37 | { 38 | $e = (new SampleExceptionHandler())->render( 39 | FileExistsException::fileExists('already/existing.jpg') 40 | ); 41 | 42 | $this->assertHttpException($e, 409); 43 | } 44 | 45 | public function test_it_returns_a_413_for_too_big_file(): void 46 | { 47 | $e = (new SampleExceptionHandler())->render( 48 | FileSizeException::fileIsTooBig(3, 2) 49 | ); 50 | 51 | $this->assertHttpException($e, 413); 52 | } 53 | 54 | public function test_it_returns_a_415_for_type_mismatch(): void 55 | { 56 | $e = (new SampleExceptionHandler())->render( 57 | FileNotSupportedException::strictTypeMismatch('text/foo', 'bar') 58 | ); 59 | 60 | $this->assertHttpException($e, 415); 61 | } 62 | 63 | public function test_it_returns_a_415_for_unknown_type(): void 64 | { 65 | $e = (new SampleExceptionHandler())->render( 66 | FileNotSupportedException::unrecognizedFileType('text/foo', 'bar') 67 | ); 68 | 69 | $this->assertHttpException($e, 415); 70 | } 71 | 72 | public function test_it_returns_a_415_for_restricted_type(): void 73 | { 74 | $e = (new SampleExceptionHandler())->render( 75 | FileNotSupportedException::mimeRestricted('text/foo', ['text/bar']) 76 | ); 77 | 78 | $this->assertHttpException($e, 415); 79 | } 80 | 81 | public function test_it_returns_a_415_for_restricted_extension(): void 82 | { 83 | $e = (new SampleExceptionHandler())->render( 84 | FileNotSupportedException::extensionRestricted('foo', ['bar']) 85 | ); 86 | 87 | $this->assertHttpException($e, 415); 88 | } 89 | 90 | public function test_it_returns_a_415_for_restricted_aggregate_type(): void 91 | { 92 | $e = (new SampleExceptionHandler())->render( 93 | FileNotSupportedException::aggregateTypeRestricted('foo', ['bar']) 94 | ); 95 | 96 | $this->assertHttpException($e, 415); 97 | } 98 | 99 | public function test_it_returns_a_500_for_other_exception_types(): void 100 | { 101 | $e = (new SampleExceptionHandler())->render( 102 | new ConfigurationException() 103 | ); 104 | 105 | $this->assertHttpException($e, 500); 106 | } 107 | 108 | public function test_it_skips_any_other_exception(): void 109 | { 110 | $e = (new SampleExceptionHandler())->render( 111 | new Exception() 112 | ); 113 | 114 | $this->assertFalse($e instanceof HttpException); 115 | } 116 | 117 | protected function assertHttpException($e, $code): void 118 | { 119 | $this->assertInstanceOf(HttpException::class, $e); 120 | /** @var HttpException $e */ 121 | $this->assertEquals($code, $e->getStatusCode()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/Integration/Helpers/FileTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('', File::cleanDirname('')); 13 | $this->assertEquals('', File::cleanDirname('/')); 14 | $this->assertEquals('', File::cleanDirname('foo.jpg')); 15 | $this->assertEquals('foo', File::cleanDirname('/foo/bar/')); 16 | $this->assertEquals('foo/bar', File::cleanDirname('/foo/bar/baz.jpg')); 17 | } 18 | 19 | public function test_it_converts_bytes_to_readable_strings(): void 20 | { 21 | $this->assertEquals('0 B', File::readableSize(0)); 22 | $this->assertEquals('1 KB', File::readableSize(1025, 0)); 23 | $this->assertEquals('1.1 MB', File::readableSize(1024 * 1024 + 1024 * 100, 2)); 24 | } 25 | 26 | public function test_it_guesses_the_extension_given_a_mime_type(): void 27 | { 28 | $this->assertEquals('png', File::guessExtension('image/png')); 29 | } 30 | 31 | public function test_it_sanitizes_filenames(): void 32 | { 33 | $this->assertEquals( 34 | 'hello-world-what-ss_new-with.you', 35 | File::sanitizeFileName("héllo/world! \\ \t whàt\'ß_new with.you?", 'en') 36 | ); 37 | } 38 | 39 | public function test_it_sanitizes_filenames_with_locale(): void 40 | { 41 | $this->assertEquals( 42 | 'hello-world-what-sz_new-with.you', 43 | File::sanitizeFileName("héllo/world! \\ \t whàt\'ß_new with.you?", 'de_at') 44 | ); 45 | } 46 | 47 | public function test_it_sanitizes_paths(): void 48 | { 49 | $this->assertEquals( 50 | 'hello/world-what-s_new-with.you', 51 | File::sanitizePath("/héllo/world! \\ \t whàt\'ς_new with.you??") 52 | ); 53 | } 54 | 55 | public function test_it_joins_path_components(): void 56 | { 57 | $this->assertEquals('', File::joinPathComponents('', '')); 58 | $this->assertEquals('foo', File::joinPathComponents('foo', '')); 59 | $this->assertEquals('foo/', File::joinPathComponents('foo/', '')); 60 | $this->assertEquals('foo/bar', File::joinPathComponents('foo', 'bar')); 61 | $this->assertEquals('foo/bar', File::joinPathComponents('foo/', 'bar')); 62 | $this->assertEquals('foo/bar', File::joinPathComponents('foo/', '/bar')); 63 | $this->assertEquals('foo/bar/baz', File::joinPathComponents('foo', '/bar/', '/baz')); 64 | $this->assertEquals('foo/baz', File::joinPathComponents('foo', '', 'baz')); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Integration/ImageManipulationTest.php: -------------------------------------------------------------------------------- 1 | getMockCallable(); 15 | $manipulation = new ImageManipulation($callback); 16 | $this->assertSame($callback, $manipulation->getCallback()); 17 | } 18 | 19 | public function test_can_get_set_output_quality(): void 20 | { 21 | $manipulation = new ImageManipulation($this->getMockCallable()); 22 | $this->assertEquals(90, $manipulation->getOutputQuality()); 23 | $manipulation->setOutputQuality(-100); 24 | $this->assertEquals(0, $manipulation->getOutputQuality()); 25 | $manipulation->setOutputQuality(500); 26 | $this->assertEquals(100, $manipulation->getOutputQuality()); 27 | $manipulation->setOutputQuality(50); 28 | $this->assertEquals(50, $manipulation->getOutputQuality()); 29 | } 30 | 31 | public function test_can_get_set_output_format(): void 32 | { 33 | $manipulation = new ImageManipulation($this->getMockCallable()); 34 | $this->assertNull($manipulation->getOutputFormat()); 35 | $manipulation->setOutputFormat('jpg'); 36 | $this->assertEquals('jpg', $manipulation->getOutputFormat()); 37 | $manipulation->outputBmpFormat(); 38 | $this->assertEquals('bmp', $manipulation->getOutputFormat()); 39 | $manipulation->outputGifFormat(); 40 | $this->assertEquals('gif', $manipulation->getOutputFormat()); 41 | $manipulation->outputPngFormat(); 42 | $this->assertEquals('png', $manipulation->getOutputFormat()); 43 | $manipulation->outputTiffFormat(); 44 | $this->assertEquals('tif', $manipulation->getOutputFormat()); 45 | $manipulation->outputWebpFormat(); 46 | $this->assertEquals('webp', $manipulation->getOutputFormat()); 47 | $manipulation->outputJpegFormat(); 48 | $this->assertEquals('jpg', $manipulation->getOutputFormat()); 49 | $manipulation->outputHeicFormat(); 50 | $this->assertEquals('heic', $manipulation->getOutputFormat()); 51 | } 52 | 53 | public function test_can_get_set_before_save_callback(): void 54 | { 55 | $callback = $this->getMockCallable(); 56 | $manipulation = new ImageManipulation($this->getMockCallable()); 57 | 58 | $this->assertNull($manipulation->getBeforeSave()); 59 | $manipulation->beforeSave($callback); 60 | $this->assertSame($callback, $manipulation->getBeforeSave()); 61 | } 62 | 63 | public function test_destination_setters(): void 64 | { 65 | $manipulation = new ImageManipulation($this->getMockCallable()); 66 | 67 | $this->assertNull($manipulation->getDisk()); 68 | $this->assertNull($manipulation->getDirectory()); 69 | $this->assertNull($manipulation->getFilename()); 70 | $this->assertFalse($manipulation->isUsingHashForFilename()); 71 | 72 | $manipulation->toDisk('tmp'); 73 | $this->assertEquals('tmp', $manipulation->getDisk()); 74 | 75 | $manipulation->toDirectory('bar'); 76 | $this->assertEquals('bar', $manipulation->getDirectory()); 77 | 78 | $manipulation->toDestination('uploads', 'bat'); 79 | $this->assertEquals('uploads', $manipulation->getDisk()); 80 | $this->assertEquals('bat', $manipulation->getDirectory()); 81 | 82 | $manipulation->useFilename('potato'); 83 | $this->assertEquals('potato', $manipulation->getFilename()); 84 | $this->assertFalse($manipulation->isUsingHashForFilename()); 85 | 86 | $manipulation->useHashForFilename(); 87 | $this->assertNull($manipulation->getFilename()); 88 | $this->assertTrue($manipulation->isUsingHashForFilename()); 89 | $this->assertEquals('md5', $manipulation->getHashFilenameAlgo()); 90 | 91 | $manipulation->useHashForFilename('sha1'); 92 | $this->assertNull($manipulation->getFilename()); 93 | $this->assertTrue($manipulation->isUsingHashForFilename()); 94 | $this->assertEquals('sha1', $manipulation->getHashFilenameAlgo()); 95 | 96 | $manipulation->useOriginalFilename(); 97 | $this->assertNull($manipulation->getFilename()); 98 | $this->assertFalse($manipulation->isUsingHashForFilename()); 99 | } 100 | 101 | public function test_get_duplicate_behaviours(): void 102 | { 103 | $manipulation = new ImageManipulation($this->getMockCallable()); 104 | $this->assertEquals( 105 | ImageManipulation::ON_DUPLICATE_INCREMENT, 106 | $manipulation->getOnDuplicateBehaviour() 107 | ); 108 | $manipulation->onDuplicateError(); 109 | $this->assertEquals( 110 | ImageManipulation::ON_DUPLICATE_ERROR, 111 | $manipulation->getOnDuplicateBehaviour() 112 | ); 113 | $manipulation->onDuplicateIncrement(); 114 | $this->assertEquals( 115 | ImageManipulation::ON_DUPLICATE_INCREMENT, 116 | $manipulation->getOnDuplicateBehaviour() 117 | ); 118 | } 119 | 120 | public function test_visibility(): void 121 | { 122 | $manipulation = new ImageManipulation($this->getMockCallable()); 123 | $this->assertNull($manipulation->getVisibility()); 124 | 125 | $manipulation->makePublic(); 126 | $this->assertEquals('public', $manipulation->getVisibility()); 127 | 128 | $manipulation->makePrivate(); 129 | $this->assertEquals('private', $manipulation->getVisibility()); 130 | 131 | $manipulation->matchOriginalVisibility(); 132 | $this->assertEquals('match', $manipulation->getVisibility()); 133 | 134 | $manipulation->setVisibility('public'); 135 | $this->assertEquals('public', $manipulation->getVisibility()); 136 | 137 | $manipulation->setVisibility('private'); 138 | $this->assertEquals('private', $manipulation->getVisibility()); 139 | 140 | $manipulation->setVisibility('match'); 141 | $this->assertEquals('match', $manipulation->getVisibility()); 142 | 143 | $manipulation->setVisibility(null); 144 | $this->assertNull($manipulation->getVisibility()); 145 | } 146 | 147 | public function test_it_can_configure_image_optimization(): void 148 | { 149 | config(['mediable.image_optimization.enabled' => true]); 150 | config(['mediable.image_optimization.optimizers' => [Pngquant::class => ['--arg']]]); 151 | 152 | $manipulation = new ImageManipulation($this->getMockCallable()); 153 | $this->assertTrue($manipulation->shouldOptimize()); 154 | $optimizerChain = $manipulation->getOptimizerChain(); 155 | $optimizers = $optimizerChain->getOptimizers(); 156 | $this->assertCount(1, $optimizers); 157 | $this->assertInstanceOf(Pngquant::class, $optimizers[0]); 158 | 159 | $manipulation->noOptimization(); 160 | $this->assertFalse($manipulation->shouldOptimize()); 161 | 162 | config(['mediable.image_optimization.enabled' => false]); 163 | $manipulation = new ImageManipulation($this->getMockCallable()); 164 | $this->assertFalse($manipulation->shouldOptimize()); 165 | 166 | $manipulation->optimize(); 167 | $this->assertTrue($manipulation->shouldOptimize()); 168 | $optimizerChain = $manipulation->getOptimizerChain(); 169 | $optimizers = $optimizerChain->getOptimizers(); 170 | $this->assertCount(1, $optimizers); 171 | $this->assertInstanceOf(Pngquant::class, $optimizers[0]); 172 | 173 | $manipulation->optimize([Jpegoptim::class => ['--arg']]); 174 | $this->assertTrue($manipulation->shouldOptimize()); 175 | $optimizerChain = $manipulation->getOptimizerChain(); 176 | $optimizers = $optimizerChain->getOptimizers(); 177 | $this->assertCount(1, $optimizers); 178 | $this->assertInstanceOf(Jpegoptim::class, $optimizers[0]); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/Integration/ImageOptimizerTest.php: -------------------------------------------------------------------------------- 1 | sampleFilePath())); 15 | 16 | $imageOptimizer = new ImageOptimizer(); 17 | $optimizerChain = $this->createMock(OptimizerChain::class); 18 | $optimizerChain->expects($this->once()) 19 | ->method('optimize'); 20 | 21 | $optimizedStream = $imageOptimizer->optimizeImage($imageStream, $optimizerChain); 22 | $imageStream->rewind(); 23 | $optimizedStream->rewind(); 24 | 25 | $this->assertNotSame($imageStream, $optimizedStream); 26 | $this->assertEquals($imageStream->getContents(), $optimizedStream->getContents()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Integration/Jobs/CreateImageVariantsTest.php: -------------------------------------------------------------------------------- 1 | makeMedia(['aggregate_type' => 'image']); 17 | $variant = 'foo'; 18 | 19 | $manipulator = $this->createMock(ImageManipulator::class); 20 | $manipulator->expects($this->once()) 21 | ->method('validateMedia') 22 | ->with($model); 23 | $manipulator->expects($this->once()) 24 | ->method('getVariantDefinition') 25 | ->with($variant) 26 | ->willReturn($this->createMock(ImageManipulation::class)); 27 | $manipulator->expects($this->once()) 28 | ->method('createImageVariant') 29 | ->with(...$this->withConsecutive([$model, $variant, false])); 30 | app()->instance(ImageManipulator::class, $manipulator); 31 | 32 | $job = new CreateImageVariants($model, $variant); 33 | $job->handle(); 34 | } 35 | 36 | public function test_it_will_trigger_image_manipulation_multiple(): void 37 | { 38 | $model1 = $this->makeMedia(['aggregate_type' => 'image']); 39 | $model2 = $this->makeMedia(['aggregate_type' => 'image']); 40 | $variant1 = 'foo'; 41 | $variant2 = 'bar'; 42 | 43 | $manipulator = $this->createMock(ImageManipulator::class); 44 | $manipulator->expects($this->exactly(2)) 45 | ->method('validateMedia') 46 | ->with(...$this->withConsecutive([$model1], [$model2])); 47 | $manipulator->expects($this->exactly(2)) 48 | ->method('getVariantDefinition') 49 | ->with(...$this->withConsecutive([$variant1], [$variant2])) 50 | ->willReturn($this->createMock(ImageManipulation::class)); 51 | $manipulator->expects($this->exactly(4)) 52 | ->method('createImageVariant') 53 | ->with(...$this->withConsecutive( 54 | [$model1, $variant1, false], 55 | [$model1, $variant2, false], 56 | [$model2, $variant1, false], 57 | [$model2, $variant2, false] 58 | )); 59 | app()->instance(ImageManipulator::class, $manipulator); 60 | 61 | $job = new CreateImageVariants( 62 | new Collection([$model1, $model2]), 63 | [$variant1, $variant2] 64 | ); 65 | $job->handle(); 66 | } 67 | 68 | public function test_it_will_trigger_image_manipulation_recreate(): void 69 | { 70 | $model = $this->makeMedia(['aggregate_type' => 'image']); 71 | $variant1 = 'foo'; 72 | $variant2 = 'bar'; 73 | 74 | $manipulator = $this->createMock(ImageManipulator::class); 75 | $manipulator->expects($this->once()) 76 | ->method('validateMedia') 77 | ->with($model); 78 | $manipulator->expects($this->exactly(2)) 79 | ->method('getVariantDefinition') 80 | ->with(...$this->withConsecutive([$variant1], [$variant2])) 81 | ->willReturn($this->createMock(ImageManipulation::class)); 82 | $manipulator->expects($this->exactly(2)) 83 | ->method('createImageVariant') 84 | ->with(...$this->withConsecutive( 85 | [$model, $variant1, true], 86 | [$model, $variant2, true] 87 | )); 88 | app()->instance(ImageManipulator::class, $manipulator); 89 | 90 | $job = new CreateImageVariants($model, [$variant1, $variant2], true); 91 | $job->handle(); 92 | } 93 | 94 | public function test_it_will_serialize_models(): void 95 | { 96 | $this->useDatabase(); 97 | $model = $this->createMedia(['aggregate_type' => 'image']); 98 | $variant = 'foo'; 99 | 100 | $manipulator = $this->createMock(ImageManipulator::class); 101 | $manipulator->expects($this->once()) 102 | ->method('validateMedia') 103 | ->with($model); 104 | $manipulator->expects($this->any()) 105 | ->method('getVariantDefinition') 106 | ->with(...$this->withConsecutive([$variant])) 107 | ->willReturn($this->createMock(ImageManipulation::class)); 108 | app()->instance(ImageManipulator::class, $manipulator); 109 | 110 | $job = new CreateImageVariants($model, [$variant], true); 111 | /** @var CreateImageVariants $result */ 112 | $result = unserialize(serialize($job)); 113 | $this->assertEquals([$model->getKey()], $result->getModels()->modelKeys()); 114 | $this->assertEquals([$variant], $result->getVariantNames()); 115 | $this->assertTrue($result->getForceRecreate()); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Integration/MediableCollectionTest.php: -------------------------------------------------------------------------------- 1 | useDatabase(); 17 | } 18 | 19 | public function test_it_can_lazy_eager_load_media(): void 20 | { 21 | $mediable = factory(SampleMediable::class)->create(); 22 | $media = factory(Media::class)->create(); 23 | $mediable->attachMedia($media, 'foo'); 24 | 25 | $collection = new MediableCollection([SampleMediable::first()]); 26 | $this->assertSame($collection, $collection->loadMedia()); 27 | $this->assertTrue($collection[0]->relationLoaded('media')); 28 | $this->assertFalse($collection[0]->media[0]->relationLoaded('originalMedia')); 29 | $this->assertFalse($collection[0]->media[0]->relationLoaded('variants')); 30 | } 31 | 32 | public function test_it_can_lazy_eager_load_media_by_tag(): void 33 | { 34 | $mediable = factory(SampleMediable::class)->create(); 35 | $media1 = factory(Media::class)->create(['id' => 1]); 36 | $media2 = factory(Media::class)->create(['id' => 2]); 37 | $mediable->attachMedia($media1, 'foo'); 38 | $mediable->attachMedia($media2, 'bar'); 39 | 40 | $collection = new MediableCollection([SampleMediable::first()]); 41 | $return = $collection->loadMedia(['bar']); 42 | 43 | $this->assertSame($collection, $return); 44 | $this->assertTrue($collection[0]->relationLoaded('media')); 45 | $this->assertEquals([2], $collection[0]->media->pluck('id')->toArray()); 46 | $this->assertFalse($collection[0]->media[0]->relationLoaded('originalMedia')); 47 | $this->assertFalse($collection[0]->media[0]->relationLoaded('variants')); 48 | } 49 | 50 | public function test_it_can_lazy_eager_load_media_by_tag_match_all(): void 51 | { 52 | $mediable = factory(SampleMediable::class)->create(); 53 | $media1 = factory(Media::class)->create(['id' => 1]); 54 | $media2 = factory(Media::class)->create(['id' => 2]); 55 | $mediable->attachMedia($media1, 'foo'); 56 | $mediable->attachMedia($media2, ['foo', 'bar', 'baz']); 57 | 58 | $collection = new MediableCollection([SampleMediable::first()]); 59 | $this->assertSame($collection, $collection->loadMedia(['foo', 'bar'], true)); 60 | $this->assertTrue($collection[0]->relationLoaded('media')); 61 | $this->assertEquals([2, 2], $collection[0]->media->pluck('id')->toArray()); 62 | $this->assertFalse($collection[0]->media[0]->relationLoaded('originalMedia')); 63 | $this->assertFalse($collection[0]->media[0]->relationLoaded('variants')); 64 | 65 | $collection = new MediableCollection([SampleMediable::first()]); 66 | $this->assertSame($collection, $collection->loadMediaMatchAll(['foo', 'bar'])); 67 | $this->assertTrue($collection[0]->relationLoaded('media')); 68 | $this->assertEquals([2, 2], $collection[0]->media->pluck('id')->toArray()); 69 | $this->assertFalse($collection[0]->media[0]->relationLoaded('originalMedia')); 70 | $this->assertFalse($collection[0]->media[0]->relationLoaded('variants')); 71 | } 72 | 73 | public function test_it_can_lazy_eager_load_media_with_variants(): void 74 | { 75 | $mediable = factory(SampleMediable::class)->create(); 76 | $media = factory(Media::class)->create(); 77 | $mediable->attachMedia($media, 'foo'); 78 | 79 | $collection = new MediableCollection([SampleMediable::first()]); 80 | $this->assertSame($collection, $collection->loadMedia([], false, true)); 81 | $this->assertTrue($collection[0]->relationLoaded('media')); 82 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 83 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 84 | 85 | $collection = new MediableCollection([SampleMediable::first()]); 86 | $this->assertSame($collection, $collection->loadMediaWithVariants([])); 87 | $this->assertTrue($collection[0]->relationLoaded('media')); 88 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 89 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 90 | } 91 | 92 | public function test_it_can_lazy_eager_load_media_with_variants_by_tag(): void 93 | { 94 | $mediable = factory(SampleMediable::class)->create(); 95 | $media1 = factory(Media::class)->create(['id' => 1]); 96 | $media2 = factory(Media::class)->create(['id' => 2]); 97 | $mediable->attachMedia($media1, 'foo'); 98 | $mediable->attachMedia($media2, 'bar'); 99 | 100 | $collection = new MediableCollection([SampleMediable::first()]); 101 | $this->assertSame($collection, $collection->loadMedia(['bar'], false, true)); 102 | $this->assertTrue($collection[0]->relationLoaded('media')); 103 | $this->assertEquals([2], $collection[0]->media->pluck('id')->toArray()); 104 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 105 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 106 | 107 | $collection = new MediableCollection([SampleMediable::first()]); 108 | $this->assertSame($collection, $collection->loadMediaWithVariants(['bar'])); 109 | $this->assertTrue($collection[0]->relationLoaded('media')); 110 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 111 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 112 | } 113 | 114 | public function test_it_can_lazy_eager_load_media_with_relations_by_tag_match_all(): void 115 | { 116 | $mediable = factory(SampleMediable::class)->create(); 117 | $media1 = factory(Media::class)->create(['id' => 1]); 118 | $media2 = factory(Media::class)->create(['id' => 2]); 119 | $mediable->attachMedia($media1, 'foo'); 120 | $mediable->attachMedia($media2, ['foo', 'bar', 'baz']); 121 | 122 | $collection = new MediableCollection([SampleMediable::first()]); 123 | $this->assertSame($collection, $collection->loadMedia(['foo', 'bar'], true, true)); 124 | $this->assertTrue($collection[0]->relationLoaded('media')); 125 | $this->assertEquals([2, 2], $collection[0]->media->pluck('id')->toArray()); 126 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 127 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 128 | 129 | $collection = new MediableCollection([SampleMediable::first()]); 130 | $this->assertSame($collection, $collection->loadMediaWithVariants(['foo', 'bar'], true)); 131 | $this->assertTrue($collection[0]->relationLoaded('media')); 132 | $this->assertEquals([2, 2], $collection[0]->media->pluck('id')->toArray()); 133 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 134 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 135 | 136 | $collection = new MediableCollection([SampleMediable::first()]); 137 | $this->assertSame($collection, $collection->loadMediaMatchAll(['foo', 'bar'], true)); 138 | $this->assertTrue($collection[0]->relationLoaded('media')); 139 | $this->assertEquals([2, 2], $collection[0]->media->pluck('id')->toArray()); 140 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 141 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 142 | 143 | $collection = new MediableCollection([SampleMediable::first()]); 144 | $this->assertSame($collection, $collection->loadMediaWithVariantsMatchAll(['foo', 'bar'])); 145 | $this->assertTrue($collection[0]->relationLoaded('media')); 146 | $this->assertEquals([2, 2], $collection[0]->media->pluck('id')->toArray()); 147 | $this->assertTrue($collection[0]->media[0]->relationLoaded('originalMedia')); 148 | $this->assertTrue($collection[0]->media[0]->relationLoaded('variants')); 149 | } 150 | 151 | public function testDelete(): void 152 | { 153 | $mediable1 = factory(SampleMediable::class)->create(['id' => 1]); 154 | $mediable2 = factory(SampleMediable::class)->create(['id' => 2]); 155 | $mediable3 = factory(SampleMediable::class)->create(['id' => 3]); 156 | $media1 = factory(Media::class)->create(['id' => 1]); 157 | $media2 = factory(Media::class)->create(['id' => 2]); 158 | $mediable1->attachMedia($media1, 'foo'); 159 | $mediable1->attachMedia($media2, ['foo', 'bar', 'baz']); 160 | $mediable2->attachMedia($media2, ['foo']); 161 | $mediable3->attachMedia($media2, ['foo']); 162 | $collection = new MediableCollection([$mediable1, $mediable2]); 163 | 164 | $collection->delete(); 165 | 166 | $mediableResults = SampleMediable::all(); 167 | $this->assertEquals([3], $mediableResults->modelKeys()); 168 | 169 | $query = $mediable1->media()->newPivotStatement(); 170 | $pivots = $query->get(); 171 | $this->assertCount(1, $pivots); 172 | $this->assertEquals("2", $pivots[0]->media_id); 173 | $this->assertEquals("3", $pivots[0]->mediable_id); 174 | $this->assertEquals("foo", $pivots[0]->tag); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /tests/Integration/SourceAdapters/SourceAdapterFactoryTest.php: -------------------------------------------------------------------------------- 1 | createMock(stdClass::class); 17 | $sourceClass = get_class($source); 18 | $adapterClass = get_class($this->createMock(SourceAdapterInterface::class)); 19 | 20 | $factory->setAdapterForClass($adapterClass, $sourceClass); 21 | $this->assertInstanceOf($adapterClass, $factory->create($source)); 22 | } 23 | 24 | public function test_it_allows_setting_adapter_for_pattern(): void 25 | { 26 | $factory = new SourceAdapterFactory; 27 | $adapterClass = get_class($this->createMock(SourceAdapterInterface::class)); 28 | 29 | $factory->setAdapterForPattern($adapterClass, '[abc][123]'); 30 | $this->assertInstanceOf($adapterClass, $factory->create('b1')); 31 | } 32 | 33 | public function test_it_throws_exception_if_invalid_adapter_for_class(): void 34 | { 35 | $factory = new SourceAdapterFactory; 36 | $this->expectException(ConfigurationException::class); 37 | $factory->setAdapterForClass(stdClass::class, stdClass::class); 38 | } 39 | 40 | public function test_it_throws_exception_if_invalid_adapter_for_pattern(): void 41 | { 42 | $factory = new SourceAdapterFactory; 43 | $this->expectException(ConfigurationException::class); 44 | $factory->setAdapterForPattern(stdClass::class, 'foo'); 45 | } 46 | 47 | public function test_it_throws_exception_if_no_match_for_class(): void 48 | { 49 | $factory = new SourceAdapterFactory; 50 | $this->expectException(ConfigurationException::class); 51 | $factory->create(new stdClass); 52 | } 53 | 54 | public function test_it_throws_exception_if_no_match_for_pattern(): void 55 | { 56 | $factory = new SourceAdapterFactory; 57 | $this->expectException(ConfigurationException::class); 58 | $factory->create('foo'); 59 | } 60 | 61 | public function test_it_returns_adapters_unmodified(): void 62 | { 63 | $factory = new SourceAdapterFactory; 64 | $adapter = $this->createMock(SourceAdapterInterface::class); 65 | 66 | $this->assertEquals($adapter, $factory->create($adapter)); 67 | } 68 | 69 | public function test_it_is_accessible_via_the_container(): void 70 | { 71 | $this->assertInstanceOf( 72 | SourceAdapterFactory::class, 73 | app('mediable.source.factory') 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Integration/UrlGenerators/LocalUrlGeneratorTest.php: -------------------------------------------------------------------------------- 1 | setupGenerator(); 17 | $this->assertEquals( 18 | public_path('uploads/foo/bar.jpg'), 19 | $generator->getAbsolutePath() 20 | ); 21 | } 22 | 23 | public function test_it_generates_url(): void 24 | { 25 | $generator = $this->setupGenerator(); 26 | $this->assertEquals('http://localhost/uploads/foo/bar.jpg', $generator->getUrl()); 27 | } 28 | 29 | public function test_it_attempts_to_generate_url_for_non_public_disk(): void 30 | { 31 | $generator = $this->setupGenerator('tmp'); 32 | $this->assertEquals('/storage/foo/bar.jpg', $generator->getUrl()); 33 | } 34 | 35 | public function test_it_accepts_public_visibility(): void 36 | { 37 | $generator = $this->setupGenerator('public_storage'); 38 | $this->assertEquals('http://localhost/storage/foo/bar.jpg', $generator->getUrl()); 39 | } 40 | 41 | public static function public_visibility_provider(): array 42 | { 43 | return [ 44 | ['uploads', true, true], 45 | ['uploads', false, false], 46 | ['tmp', true, false], 47 | ['tmp', false, false], 48 | ['public_storage', true, true], 49 | ['public_storage', false, false], 50 | ]; 51 | } 52 | 53 | #[DataProvider('public_visibility_provider')] 54 | public function test_it_checks_public_visibility( 55 | string $disk, 56 | bool $public, 57 | bool $expectedAccessibility 58 | ): void { 59 | $generator = $this->setupGenerator($disk, $public); 60 | $this->assertSame($expectedAccessibility, $generator->isPubliclyAccessible()); 61 | } 62 | 63 | public function test_it_checks_public_visibility_mock_disk(): void 64 | { 65 | $filesystem = $this->createConfiguredMock( 66 | FilesystemManager::class, 67 | [ 68 | 'disk' => Storage::fake('uploads') 69 | ] 70 | ); 71 | $generator = new LocalUrlGenerator(config(), $filesystem); 72 | 73 | $media = factory(Media::class)->make( 74 | [ 75 | 'disk' => 'uploads', 76 | 'directory' => 'foo', 77 | 'filename' => 'bar', 78 | 'extension' => 'jpg' 79 | ] 80 | ); 81 | $this->seedFileForMedia($media); 82 | $generator->setMedia($media); 83 | $this->assertTrue($generator->isPubliclyAccessible()); 84 | } 85 | 86 | protected function setupGenerator( 87 | $disk = 'uploads', 88 | ?bool $public = null 89 | ): LocalUrlGenerator { 90 | /** @var Media $media */ 91 | $media = factory(Media::class)->make( 92 | [ 93 | 'disk' => $disk, 94 | 'directory' => 'foo', 95 | 'filename' => 'bar', 96 | 'extension' => 'jpg' 97 | ] 98 | ); 99 | $this->useFilesystem($disk); 100 | $this->seedFileForMedia($media); 101 | 102 | if ($public === true) { 103 | $media->makePublic(); 104 | } 105 | if ($public === false) { 106 | $media->makePrivate(); 107 | } 108 | $generator = new LocalUrlGenerator(config(), app(FilesystemManager::class)); 109 | $generator->setMedia($media); 110 | return $generator; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/Integration/UrlGenerators/S3UrlGeneratorTest.php: -------------------------------------------------------------------------------- 1 | s3ConfigLoaded()) { 16 | $this->markTestSkipped('S3 Credentials not available.'); 17 | } 18 | parent::setup(); 19 | } 20 | 21 | public function tearDown(): void 22 | { 23 | $filesystemManager = app(FilesystemManager::class); 24 | $filesystemManager->disk('s3') 25 | ->delete($this->getMedia()->getDiskPath()); 26 | 27 | parent::tearDown(); 28 | } 29 | 30 | public function test_it_generates_absolute_path(): void 31 | { 32 | $generator = $this->setupGenerator(); 33 | $this->assertEquals( 34 | sprintf( 35 | 'https://%s.s3.%s.amazonaws.com/%s/foo/bar.jpg', 36 | env('S3_BUCKET'), 37 | env('S3_REGION'), 38 | config('filesystems.disks.s3.root') 39 | ), 40 | $generator->getAbsolutePath() 41 | ); 42 | } 43 | 44 | public function test_it_generates_url(): void 45 | { 46 | $generator = $this->setupGenerator(); 47 | $this->assertEquals( 48 | sprintf( 49 | 'https://%s.s3.%s.amazonaws.com/%s/foo/bar.jpg', 50 | env('S3_BUCKET'), 51 | env('S3_REGION'), 52 | config('filesystems.disks.s3.root') 53 | ), 54 | $generator->getUrl() 55 | ); 56 | } 57 | 58 | public function test_it_generates_temporary_url(): void 59 | { 60 | $generator = $this->setupGenerator(); 61 | $url = $generator->getTemporaryUrl(Carbon::now()->addDay()); 62 | [$uri, $queryString] = explode('?', $url); 63 | $this->assertEquals( 64 | sprintf( 65 | 'https://%s.s3.%s.amazonaws.com/%s/foo/bar.jpg', 66 | env('S3_BUCKET'), 67 | env('S3_REGION'), 68 | config('filesystems.disks.s3.root') 69 | ), 70 | $uri 71 | ); 72 | parse_str($queryString, $queryParams); 73 | $this->assertArrayHasKey( 74 | 'X-Amz-Credential', 75 | $queryParams 76 | ); 77 | $this->assertArrayHasKey( 78 | 'X-Amz-Expires', 79 | $queryParams 80 | ); 81 | $this->assertArrayHasKey( 82 | 'X-Amz-Algorithm', 83 | $queryParams 84 | ); 85 | $this->assertArrayHasKey( 86 | 'X-Amz-Signature', 87 | $queryParams 88 | ); 89 | } 90 | 91 | protected function setupGenerator(): S3UrlGenerator 92 | { 93 | $media = $this->getMedia(); 94 | $this->useFilesystem('s3'); 95 | $filesystemManager = app(FilesystemManager::class); 96 | $filesystemManager->disk('s3') 97 | ->put( 98 | $media->getDiskPath(), 99 | file_get_contents(dirname(__DIR__, 2) . '/_data/plank.png') 100 | ); 101 | $generator = new S3UrlGenerator(config(), $filesystemManager); 102 | $generator->setMedia($media); 103 | return $generator; 104 | } 105 | 106 | private function getMedia(): Media 107 | { 108 | return $this->makeMedia( 109 | [ 110 | 'disk' => 's3', 111 | 'directory' => 'foo', 112 | 'filename' => 'bar', 113 | 'extension' => 'jpg' 114 | ] 115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Integration/UrlGenerators/UrlGeneratorFactoryTest.php: -------------------------------------------------------------------------------- 1 | make(['disk' => 'uploads']); 42 | 43 | $factory->setGeneratorForFilesystemDriver($class, 'local'); 44 | $result = $factory->create($media); 45 | $this->assertInstanceOf($class, $result); 46 | } 47 | 48 | public function test_it_throws_exception_for_invalid_generator(): void 49 | { 50 | $factory = new UrlGeneratorFactory; 51 | $class = get_class($this->createMock(stdClass::class)); 52 | $this->expectException(MediaUrlException::class); 53 | $factory->setGeneratorForFilesystemDriver($class, 'foo'); 54 | } 55 | 56 | public function test_it_throws_exception_if_cant_map_to_driver(): void 57 | { 58 | $factory = new UrlGeneratorFactory; 59 | $media = factory(Media::class)->make(); 60 | $this->expectException(MediaUrlException::class); 61 | $factory->create($media); 62 | } 63 | 64 | public function test_it_follows_scoped_prefix(): void 65 | { 66 | if (version_compare($this->app->version(), '9.30.0', '<')) { 67 | $this->markTestSkipped("scoped disk prefixes are only supported in laravel 9.30.0+"); 68 | } 69 | // TODO: league/flysystem-path-prefixing requires PHP 8, we still support 7.4 70 | // so can't include it in composer.json yet. To be fixed in next major version 71 | if (!class_exists(PathPrefixedAdapter::class)) { 72 | $this->markTestSkipped("path prefixing not installed"); 73 | } 74 | $factory = new UrlGeneratorFactory; 75 | $factory->setGeneratorForFilesystemDriver(LocalUrlGenerator::class, 'local'); 76 | /** @var Media $media */ 77 | $media = factory(Media::class)->make(['disk' => 'scoped']); 78 | $result = $factory->create($media); 79 | $this->assertInstanceOf(LocalUrlGenerator::class, $result); 80 | $this->assertEquals('/storage/scoped/' . $media->getDiskPath(), $result->getUrl()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Mocks/MediaSoftDelete.php: -------------------------------------------------------------------------------- 1 | transformMediaUploadException($e); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Mocks/SampleMediable.php: -------------------------------------------------------------------------------- 1 | withFactories(__DIR__ . '/Factories'); 27 | } 28 | 29 | protected function getPackageProviders($app) 30 | { 31 | return [ 32 | MediableServiceProvider::class 33 | ]; 34 | } 35 | 36 | protected function getPackageAliases($app) 37 | { 38 | return [ 39 | 'MediaUploader' => \Plank\Mediable\Facades\MediaUploader::class, 40 | 'ImageManipulator' => \Plank\Mediable\Facades\ImageManipulator::class, 41 | ]; 42 | } 43 | 44 | protected function getEnvironmentSetUp($app) 45 | { 46 | if (file_exists(dirname(__DIR__) . '/.env')) { 47 | Dotenv::createImmutable(dirname(__DIR__))->load(); 48 | } 49 | //use in-memory database 50 | $app['config']->set('database.connections.testing', [ 51 | 'driver' => 'sqlite', 52 | 'database' => ':memory:', 53 | 'prefix' => '' 54 | ]); 55 | $app['config']->set('database.default', 'testing'); 56 | $app['config']->set('filesystems.default', 'uploads'); 57 | $app['config']->set('filesystems.disks', [ 58 | //private local storage 59 | 'tmp' => [ 60 | 'driver' => 'local', 61 | 'root' => storage_path('tmp'), 62 | 'visibility' => 'private' 63 | ], 64 | 'novisibility' => [ 65 | 'driver' => 'local', 66 | 'root' => storage_path('tmp'), 67 | ], 68 | //public local storage 69 | 'uploads' => [ 70 | 'driver' => 'local', 71 | 'root' => public_path('uploads'), 72 | 'url' => 'http://localhost/uploads', 73 | 'visibility' => 'public' 74 | ], 75 | 'public_storage' => [ 76 | 'driver' => 'local', 77 | 'root' => storage_path('public'), 78 | 'url' => 'http://localhost/storage', 79 | 'visibility' => 'public', 80 | ], 81 | 's3' => [ 82 | 'driver' => 's3', 83 | 'key' => env('S3_KEY'), 84 | 'secret' => env('S3_SECRET'), 85 | 'region' => env('S3_REGION'), 86 | 'bucket' => env('S3_BUCKET'), 87 | 'version' => 'latest', 88 | 'visibility' => 'public', 89 | // set random root to avoid parallel test runs from deleting each other's files 90 | 'root' => Factory::create()->md5 91 | ], 92 | 'scoped' => [ 93 | 'driver' => 'scoped', 94 | 'disk' => 'tmp', 95 | 'prefix' => 'scoped' 96 | ], 97 | ]); 98 | 99 | $app['config']->set('mediable.allowed_disks', [ 100 | 'tmp', 101 | 'novisibility', 102 | 'uploads' 103 | ]); 104 | 105 | $app['config']->set('mediable.image_optimization.enabled', false); 106 | 107 | if (class_exists(\Intervention\Image\Drivers\Imagick\Driver::class) 108 | && class_exists('Imagick') 109 | ) { 110 | $app->instance( 111 | ImageManager::class, 112 | new ImageManager(new \Intervention\Image\Drivers\Imagick\Driver()) 113 | ); 114 | } elseif (class_exists(\Intervention\Image\Drivers\Gd\Driver::class)) { 115 | $app->instance( 116 | ImageManager::class, 117 | new ImageManager(new \Intervention\Image\Drivers\Gd\Driver()) 118 | ); 119 | } 120 | } 121 | 122 | protected function getPrivateProperty($class, $property_name): \ReflectionProperty 123 | { 124 | $reflector = new ReflectionClass($class); 125 | $property = $reflector->getProperty($property_name); 126 | $property->setAccessible(true); 127 | return $property; 128 | } 129 | 130 | protected function getPrivateMethod($class, $method_name): \ReflectionMethod 131 | { 132 | $reflector = new ReflectionClass($class); 133 | $method = $reflector->getMethod($method_name); 134 | $method->setAccessible(true); 135 | return $method; 136 | } 137 | 138 | protected function seedFileForMedia(Media $media, $contents = ''): void 139 | { 140 | app('filesystem')->disk($media->disk)->put( 141 | $media->getDiskPath(), 142 | $contents, 143 | config("filesystems.disks.{$media->disk}.visibility") 144 | ); 145 | } 146 | 147 | protected function s3ConfigLoaded(): bool 148 | { 149 | return env('S3_KEY') && env('S3_SECRET') && env('S3_REGION') && env('S3_BUCKET'); 150 | } 151 | 152 | protected function useDatabase(): void 153 | { 154 | $this->app->useDatabasePath(dirname(__DIR__)); 155 | $this->loadMigrationsFrom( 156 | [ 157 | '--path' => [ 158 | dirname(__DIR__) . '/migrations', 159 | __DIR__ . '/migrations' 160 | ] 161 | ] 162 | ); 163 | } 164 | 165 | protected function useFilesystem($disk): void 166 | { 167 | if (!$this->app['config']->has('filesystems.disks.' . $disk)) { 168 | return; 169 | } 170 | $root = $this->app['config']->get('filesystems.disks.' . $disk . '.root'); 171 | $filesystem = $this->app->make(Filesystem::class); 172 | $filesystem->cleanDirectory($root); 173 | } 174 | 175 | protected function useFilesystems(): void 176 | { 177 | $disks = $this->app['config']->get('filesystems.disks'); 178 | foreach ($disks as $disk) { 179 | $this->useFilesystem($disk); 180 | } 181 | } 182 | 183 | protected static function sampleFilePath(): string 184 | { 185 | return realpath(__DIR__ . '/_data/plank.png'); 186 | } 187 | 188 | protected static function alternateFilePath(): string 189 | { 190 | return realpath(__DIR__ . '/_data/plank2.png'); 191 | } 192 | 193 | protected static function remoteFilePath(): string 194 | { 195 | return 'https://raw.githubusercontent.com/plank/laravel-mediable/master/tests/_data/plank.png'; 196 | } 197 | 198 | protected function sampleFile() 199 | { 200 | return Utils::tryFopen(TestCase::sampleFilePath(), 'r'); 201 | } 202 | 203 | protected function makeMedia(array $attributes = []): Media 204 | { 205 | return factory(Media::class)->make($attributes); 206 | } 207 | 208 | protected function createMedia(array $attributes = []): Media 209 | { 210 | return factory(Media::class)->create($attributes); 211 | } 212 | 213 | /** 214 | * @return callable&MockObject 215 | */ 216 | protected function getMockCallable(): callable 217 | { 218 | return $this->createPartialMock(MockCallable::class, ['__invoke']); 219 | } 220 | 221 | /** 222 | * @param array $firstCallArguments 223 | * @param array ...$consecutiveCallsArguments 224 | * 225 | * @return \Generator> 226 | */ 227 | public static function withConsecutive(array $firstCallArguments, array ...$consecutiveCallsArguments): \Generator 228 | { 229 | foreach ($consecutiveCallsArguments as $consecutiveCallArguments) { 230 | self::assertSameSize($firstCallArguments, $consecutiveCallArguments, 'Each expected arguments list need to have the same size.'); 231 | } 232 | 233 | $allConsecutiveCallsArguments = [$firstCallArguments, ...$consecutiveCallsArguments]; 234 | 235 | $numberOfArguments = count($firstCallArguments); 236 | $argumentList = []; 237 | for ($argumentPosition = 0; $argumentPosition < $numberOfArguments; $argumentPosition++) { 238 | $argumentList[$argumentPosition] = array_column($allConsecutiveCallsArguments, $argumentPosition); 239 | } 240 | 241 | $mockedMethodCall = 0; 242 | $callbackCall = 0; 243 | foreach ($argumentList as $index => $argument) { 244 | yield new Callback( 245 | static function (mixed $actualArgument) use ($argumentList, &$mockedMethodCall, &$callbackCall, $index, $numberOfArguments): bool { 246 | $expected = $argumentList[$index][$mockedMethodCall] ?? null; 247 | 248 | $callbackCall++; 249 | $mockedMethodCall = (int) ($callbackCall / $numberOfArguments); 250 | 251 | if ($expected instanceof Constraint) { 252 | self::assertThat($actualArgument, $expected); 253 | } else { 254 | self::assertEquals($expected, $actualArgument); 255 | } 256 | 257 | return true; 258 | }, 259 | ); 260 | } 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /tests/_data/plank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plank/laravel-mediable/c769d6347cab33b52ddbc06589f16ee67fc7b3f8/tests/_data/plank.png -------------------------------------------------------------------------------- /tests/_data/plank2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plank/laravel-mediable/c769d6347cab33b52ddbc06589f16ee67fc7b3f8/tests/_data/plank2.png -------------------------------------------------------------------------------- /tests/migrations/2016_06_27_000001_create_mediable_test_tables.php: -------------------------------------------------------------------------------- 1 | increments('id'); 20 | $table->timestamps(); 21 | $table->softDeletes(); 22 | }); 23 | 24 | Schema::connection($this->getConnectionName())->table('media', function (Blueprint $table) { 25 | $table->softDeletes(); 26 | }); 27 | 28 | Schema::create('prefixed_mediables', function (Blueprint $table) { 29 | $table->integer('media_id')->unsigned(); 30 | $table->string('mediable_type'); 31 | $table->integer('mediable_id')->unsigned(); 32 | $table->string('tag'); 33 | $table->integer('order')->unsigned(); 34 | 35 | $table->primary(['media_id', 'mediable_type', 'mediable_id', 'tag']); 36 | $table->index(['mediable_id', 'mediable_type']); 37 | $table->index('tag'); 38 | $table->index('order'); 39 | $table->foreign('media_id')->references('id')->on('media') 40 | ->onDelete('cascade'); 41 | }); 42 | } 43 | 44 | /** 45 | * Reverse the migrations. 46 | * 47 | * @return void 48 | */ 49 | public function down(): void 50 | { 51 | Schema::connection($this->getConnectionName())->table('media', function (Blueprint $table) { 52 | $table->dropColumn('deleted_at'); 53 | }); 54 | Schema::dropIfExists('sample_mediables'); 55 | Schema::dropIfExists('prefixed_mediables'); 56 | } 57 | 58 | /** 59 | * Get the connection name that is used by the package. 60 | * 61 | * @return string|null 62 | */ 63 | public function getConnectionName(): ?string 64 | { 65 | return config('mediable.connection_name', $this->getConnection()); 66 | } 67 | }; 68 | --------------------------------------------------------------------------------