├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONFIG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── STAPLER.md ├── composer.json ├── config └── paperclip.php ├── phpstan.neon.dist ├── phpunit.xml.dist ├── src ├── Attachment │ ├── Attachment.php │ ├── AttachmentData.php │ └── AttachmentFactory.php ├── Config │ ├── AbstractConfig.php │ ├── PaperclipConfig.php │ ├── StaplerConfig.php │ ├── Steps │ │ ├── AutoOrientStep.php │ │ ├── ResizeStep.php │ │ └── VariantStep.php │ ├── Variant.php │ └── VariantList.php ├── Console │ └── Commands │ │ └── RefreshAttachmentCommand.php ├── Contracts │ ├── AttachableInterface.php │ ├── AttachmentDataInterface.php │ ├── AttachmentFactoryInterface.php │ ├── AttachmentInterface.php │ ├── Config │ │ └── ConfigInterface.php │ ├── FileHandlerFactoryInterface.php │ └── Path │ │ └── InterpolatorInterface.php ├── Events │ ├── AttachmentSavedEvent.php │ ├── ProcessingExceptionEvent.php │ └── TemporaryFileFailedToBeDeletedEvent.php ├── Exceptions │ ├── ReprocessingFailureException.php │ └── VariantProcessFailureException.php ├── Handler │ └── FileHandlerFactory.php ├── Model │ └── PaperclipTrait.php ├── Path │ ├── InterpolatingTarget.php │ └── Interpolator.php └── Providers │ └── PaperclipServiceProvider.php └── tests ├── Attachment ├── AttachmentDataTest.php ├── AttachmentSerializationTest.php └── AttachmentTest.php ├── Config ├── PaperclipConfigTest.php ├── Steps │ ├── AutoOrientStepTest.php │ └── ResizeStepTest.php └── VariantTest.php ├── Helpers ├── CallableClass.php ├── Hooks │ └── SpyCallableHook.php ├── Model │ └── TestModel.php └── VariantStrategies │ ├── TestNoChangesStrategy.php │ └── TestTextToHtmlStrategy.php ├── Integration ├── PaperclipAttachmentStaplerCompatibilityTest.php ├── PaperclipBasicAttachmentTest.php ├── PaperclipConfigurationErrorsTest.php ├── PaperclipFluentConfigurationTest.php └── PaperclipReprocessAttachmentTest.php ├── Path ├── InterpolatingTargetTest.php └── InterpolatorTest.php ├── ProvisionedTestCase.php ├── TestCase.php └── resources ├── alternative.txt ├── empty.gif ├── picture.png ├── rotated.jpg └── source.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 8.0 5 | - 8.1 6 | 7 | install: travis_wait composer install 8 | 9 | script: 10 | - mkdir -p build/logs 11 | - vendor/bin/phpunit 12 | 13 | notifications: 14 | email: 15 | on_success: never 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Laravel 9 and up 4 | 5 | ### [5.0.6] - 2024-09-16 6 | 7 | - Updated to require czim/file-handling package with security fix. 8 | See [security update description](https://github.com/czim/file-handling/blob/master/SECURITY.md) 9 | of that package for details. 10 | 11 | ### [5.0.5] - 2024-09-10 12 | 13 | - Patched vulnerability from czim/file-handling package. 14 | - Extracted attachment instance creation in factory to improve extensibility. 15 | - Fixed printable check for null input in path interpolator. 16 | - Made json variants data handling more robust. 17 | - Laravel 10+ and PHPUnit 10+ support. 18 | 19 | ### [5.0.0] - 2022-10-31 20 | 21 | There are quite a few breaking changes: 22 | 23 | - PHP 8.1+ required. 24 | - Laravel 9+ required. 25 | - Many public methods now have explicit/strict types. 26 | - Some methods that were previously fluent syntax are no longer (return `void`). 27 | - The event classes nog longer have public accessors, but `public readonly` properties. 28 | - The exception classes now extend `\RuntimeException` instead of `\Exception`. 29 | - A config option `paperclip.datetime.format` has been added, defaults to `'c'`. 30 | This is used to ensure that `Attachment::updatedAt()` can be consistently cast to string for compatibility. 31 | 32 | However: 33 | 34 | - Config syntax and fluent attachment config DTOs are virtually the same. 35 | Old configurations should still work. 36 | 37 | It would be a good idea to carefully walk through any code extending or hooking into Paperclip logic 38 | after upgrading! 39 | 40 | 41 | ## Laravel 8 and 9 42 | 43 | ### [4.0.2] - 2024-09-18 44 | 45 | - Updated to require czim/file-handling package with security fix. 46 | See [security update description](https://github.com/czim/file-handling/blob/master/SECURITY.md) 47 | of that package for details. 48 | 49 | ### [4.0.1] - 2022-07-14 50 | 51 | Fixed return value for setAttribute not matching Eloquent internals (wimski). 52 | 53 | ### [4.0.0] - 2021-11-30 54 | 55 | Added PHP 8.0 and 8.1 support (thanks Miljoen!). 56 | 57 | ## Laravel 7 58 | 59 | ### [3.2.2] - 2020-12-19 60 | 61 | Fixed an issue with variant step configuration where the order of variant steps was reversed unintentionally. 62 | This caused issues with auto-orient & resize steps in combination. 63 | 64 | ### [3.2.1] - 2020-06-18 65 | 66 | Attachment setters now call `clearTarget()` to reset the interpolator. 67 | This is highly unlikely to make a difference for your setup. 68 | This may help you if you are attempting to clone data, f.i. between models, at the Attachment object level. 69 | 70 | ### [3.2.0] - 2020-05-13 71 | 72 | Potentially breaking changes here! 73 | 74 | Now depends on file-handling 2. All interfaces in that package have been updated to make use of PHP 7.1+ features. 75 | If you have custom variant strategies or extend these classes, update their method signatures. 76 | This should be easy and take no more than a few minutes. 77 | 78 | ### [3.1.0] - 2020-04-05 79 | 80 | Fixed issues with attribute handling, replaced attribute hack with nicer model-event based approach (Thanks to Austen Cameron). 81 | 82 | ### [3.0.1] - 2020-03-19 83 | 84 | Support for Laravel 7, not backwards compatible. 85 | 86 | ## Laravel 5.8 and 6 87 | 88 | ### [2.7.5] - 2020-06-18 89 | 90 | See changes for 3.2.1. 91 | 92 | ### [2.7.4] - 2020-03-05 93 | 94 | Now fires an event (`AttachmentSavedEvent`) on saving an attachment. 95 | 96 | ### [2.7.3] - 2019-12-27 97 | 98 | Now fires events rather than throws exceptions on processing errors. 99 | Added `paperclip.processing.errors.events` boolean configuration toggle for this. 100 | This allows mass reprocessing to skip attachments with errors. 101 | 102 | ### [2.7.2] - 2019-07-21 103 | 104 | Artisan `paperclip:refresh` command now has `--variants=` option to refresh only specific variants. 105 | 106 | ### [2.7.1] - 2019-07-19 107 | 108 | Temporary files are now deleted after variants are processed. 109 | 110 | ### [2.7.0] - 2019-03-01 111 | 112 | Updated to support Laravel 5.8. 113 | No functional changes. 114 | 115 | 116 | ## Laravel 5.5 and up 117 | 118 | ### [2.6.3] - 2019-07-21 119 | 120 | See changes for 2.7.2. 121 | 122 | ### [2.6.2] - 2019-07-19 123 | 124 | See changes for 2.7.1. 125 | 126 | ### [2.6.1] - 2019-01-07 127 | 128 | Minor cloud storage fix. 129 | 130 | ### [2.6.0] - 2018-11-04 131 | 132 | Rewrote configuration handling. This used to be all-array, now it is object-based. 133 | 134 | This includes a breaking change for basic usage: Stapler configuration support is not enabled by default. 135 | To enable it, set the `paperclip.config.mode` configuration option to `'stapler'`. 136 | 137 | It may also break custom extensions of paperclip classes. Note especially the interface change for the `Attachment` class: `setConfig()` now takes a `ConfigInterface` object, instead of an array. 138 | This is unlikely to affect the average application of this package. 139 | 140 | Additionally, support has been added for setting a default URL that is returned when no file is stored. 141 | This must be set per attachment, and may set per variant. 142 | 143 | Further, configuration options for default variants has improved. 144 | It is now possible to always merge in the default variants into any and all specific attachment variants, 145 | using the `paperclip.variants.merge-default` setting. 146 | 147 | The [CONFIG](CONFIG.md) readme has been updated to reflect these changes. 148 | 149 | 150 | ### [2.5.7] - 2018-10-29 151 | 152 | Added possibility to configure variants using fluent syntax configuration objects. 153 | This is entirely optional. The documentation has been updated to reflect this. 154 | 155 | 156 | ### [2.5.6] - 2018-10-25 157 | 158 | Improved configuration defaults and analysis for storage and base URL. 159 | If no storage driver is configured, or if it is incorrectly configured, a descriptive exception is thrown. 160 | If no base-URL could be determined, an exception is thrown. 161 | 162 | It is no longer required to set `paperclip.storage.base-urls` entries if the relevant `filesystems.disks..url` is set. 163 | 164 | ### [2.5.5] - 2018-10-14 165 | 166 | Now allows configuration of deletion hash through `paperclip.delete-hash` key. 167 | 168 | ### [2.5.4] - 2018-09-27 169 | 170 | Fixed issue with serialization of Attachment instance (and, by extension, any models with the paperclip trait). 171 | This *may* include a breaking change, but only if you changed service provision or modified this package's instantiation or factory logic. 172 | 173 | ### [2.5.3] - 2018-09-08 174 | 175 | Fixed issue with Laravel `5.6.37` and up (with a hack). 176 | Fixed a broken config reference to filesystem disk 'local'-checking. 177 | 178 | ### [2.5.2] - 2018-06-28 179 | 180 | Fixed issue where variant URLs and storage paths made use of the original name, rather than the variant's name (and extension). 181 | 182 | ### [2.5.1] - 2018-05-13 183 | 184 | Now actually uses `path.interpolator` configuration setting. 185 | 186 | 187 | ### [2.5.0] - 2018-05-13 188 | 189 | There are quite a few breaking changes here! 190 | Please take care when updating, this will likely affect any project relying on this package. 191 | 192 | This now relies on version `^1.0` for [czim/file-handling](https://github.com/czim/file-handling), which has many breaking changes of its own. Please consider [its changelog](https://github.com/czim/file-handling/blob/master/CHANGELOG.md) too. 193 | 194 | 195 | - Removed deprecated `isFilled()` method from `Attachment` (was replaced by `exists()`). 196 | - Configuration changes: 197 | - Old configuration files will break! Please update accordingly or re-publish when upgrading. 198 | - The `path.base-path` key is now replaced by `path.original`. This should now include placeholders to make a *full file path including the filename*, as opposed to only a directory. Note that this makes the path interpolation logic more in line with the Stapler handled it. 199 | - A `path.variant` key is added, where a path can be defined similar to `path.original`. If this is set, it may be used to make an alternative structure for your variant paths (as opposed to the original file). 200 | - It is recommended to use `:variant` and `:filename` in your placeholdered path for the `path.original` (and `path.variant`) value. See [the config](https://github.com/czim/laravel-paperclip/blob/97d02c77ce724f3e47acb0e17ad3e54e17aa5f12/config/paperclip.php#L65) for an example. 201 | - `:url` is no longer a usable path interpolation placeholder. 202 | - Attachment changes: 203 | - The `AttachmentInterface` has been segregated into main and data interfaces (`AttachmentDataInterface`). 204 | - A historical set of attachment data is provided to the interpolator when handling queued deletes. 205 | This more accurately reflect the values used to create the file that is to be deleted. 206 | - Deletion queueing is now done using a `Target` instance and variant names, and uses fixed historical state saving to fix a number of (potential) issues. 207 | 208 | - Interpolator changes: 209 | - The path interpolator now depends on `AttachmentDataInterface` (and its added `getInstanceKey()` method). This is only likely to cause issues if you have extended or implemented your own interpolator. 210 | - The `url()` method and placeholder have been removed and will no longer be interpolated. 211 | This was done to prevent the risk of endless recursion. 212 | (It made no sense to me, anyway: an correct url is the *result* of the interpolation; how could it sensibly be used to interpolate its own result?). 213 | If you do use this placeholder, please submit an issue with details on how and why, so we can think of a safe solution. 214 | 215 | 216 | ## Laravel 5.4 and below 217 | 218 | ### [2.1.0] - 2019-02-11 219 | 220 | Updates added for 2.6.1. 221 | 222 | ### [2.0.1] - 2018-06-28 223 | 224 | See 2.5.2. 225 | 226 | ### [2.0.0] - 2018-05-13 227 | 228 | This merges the changes for 2.5.0 and 2.5.1 in a new major version for Laravel 5.4 and earlier. 229 | 230 | [5.0.6]: https://github.com/czim/laravel-paperclip/compare/5.0.5...5.0.6 231 | [5.0.5]: https://github.com/czim/laravel-paperclip/compare/5.0.0...5.0.5 232 | [5.0.0]: https://github.com/czim/laravel-paperclip/compare/4.0.1...5.0.0 233 | 234 | [4.0.2]: https://github.com/czim/laravel-paperclip/compare/4.0.1...4.0.2 235 | [4.0.1]: https://github.com/czim/laravel-paperclip/compare/4.0.0...4.0.1 236 | [4.0.0]: https://github.com/czim/laravel-paperclip/compare/3.2.1...4.0.0 237 | 238 | [3.2.1]: https://github.com/czim/laravel-paperclip/compare/3.2.0...3.2.1 239 | [3.2.0]: https://github.com/czim/laravel-paperclip/compare/3.1.0...3.2.0 240 | [3.1.0]: https://github.com/czim/laravel-paperclip/compare/3.0.1...3.1.0 241 | [3.0.1]: https://github.com/czim/laravel-paperclip/compare/2.7.4...3.0.1 242 | 243 | [2.7.5]: https://github.com/czim/laravel-paperclip/compare/2.7.4...2.7.5 244 | [2.7.4]: https://github.com/czim/laravel-paperclip/compare/2.7.3...2.7.4 245 | [2.7.3]: https://github.com/czim/laravel-paperclip/compare/2.7.2...2.7.3 246 | [2.7.2]: https://github.com/czim/laravel-paperclip/compare/2.7.1...2.7.2 247 | [2.7.1]: https://github.com/czim/laravel-paperclip/compare/2.7.0...2.7.1 248 | [2.7.0]: https://github.com/czim/laravel-paperclip/compare/2.6.1...2.7.0 249 | 250 | [2.6.3]: https://github.com/czim/laravel-paperclip/compare/2.6.2...2.6.3 251 | [2.6.2]: https://github.com/czim/laravel-paperclip/compare/2.6.1...2.6.2 252 | [2.6.1]: https://github.com/czim/laravel-paperclip/compare/2.6.0...2.6.1 253 | [2.6.0]: https://github.com/czim/laravel-paperclip/compare/2.5.7...2.6.0 254 | 255 | [2.5.7]: https://github.com/czim/laravel-paperclip/compare/2.5.6...2.5.7 256 | [2.5.6]: https://github.com/czim/laravel-paperclip/compare/2.5.5...2.5.6 257 | [2.5.5]: https://github.com/czim/laravel-paperclip/compare/2.5.4...2.5.5 258 | [2.5.4]: https://github.com/czim/laravel-paperclip/compare/2.5.3...2.5.4 259 | [2.5.3]: https://github.com/czim/laravel-paperclip/compare/2.5.2...2.5.3 260 | [2.5.2]: https://github.com/czim/laravel-paperclip/compare/2.5.1...2.5.2 261 | [2.5.1]: https://github.com/czim/laravel-paperclip/compare/2.5.0...2.5.1 262 | [2.5.0]: https://github.com/czim/laravel-paperclip/compare/1.5.2...2.5.0 263 | 264 | [2.1.0]: https://github.com/czim/laravel-paperclip/compare/2.0.1...2.1.0 265 | [2.0.1]: https://github.com/czim/laravel-paperclip/compare/2.0.0...2.0.1 266 | [2.0.0]: https://github.com/czim/laravel-paperclip/compare/1.0.3...2.0.0 267 | -------------------------------------------------------------------------------- /CONFIG.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Stapler Compatibility 4 | 5 | For those used to working with [codesleeve/stapler](https://github.com/CodeSleeve/stapler), here is more information on [Stapler compatibility and configuration](STAPLER.md). 6 | 7 | 8 | ## Variants 9 | 10 | The main thing to configure for the average attachment, is its `variants`. 11 | Note that this is not required (an `original` version of the attachment is always available). 12 | 13 | ### Defining an attachment without any variants 14 | 15 | A model with the following constructor would have an `'image'` attachment without any variants. 16 | 17 | ```php 18 | hasAttachedFile('image'); 22 | } 23 | ``` 24 | 25 | 26 | ### Configuration options 27 | 28 | - `variants` (array of arrays) 29 | Configured variants for the attachment. 30 | - `url` (string) 31 | The default fallback URL to return when no attachment is stored. 32 | - `urls` (array of strings) 33 | A list of fallback URLs to return for each variant, when no attachment is stored. 34 | - `extensions` (array of strings) 35 | A list of extensions, keyed by variant name, for variants that are stored with an extension different from the original file. 36 | - `types` (array of strings) 37 | A list of mimetypes, keyed by variant name, for variants that are stored with a mimetype different from the original file. 38 | - `keep-old-files` (boolean) 39 | Whether to not to delete previously attached files before storing a new attachment. 40 | - `preserve-files` (boolean) 41 | Whether to keep files even after a model is deleted. 42 | - `before` (string) 43 | To set a hook to call before a new file is stored. 44 | - `after` (string) 45 | To set a hook to call after a new file is stored. 46 | - `storage` (string) 47 | The Laravel storage disk to use. This allows overriding the default configured storage. 48 | - `path` (string) 49 | The path, with placeholders. Further information below. 50 | - `variant-path` (string) 51 | The path to use for variants, with placeholders. Just like `path`, but only for variants. 52 | 53 | Some of these options can also be set globally in the paperclip config file. 54 | If these values are not set for the attachment, the global values are used. 55 | 56 | 57 | ### Object configuration 58 | 59 | To make for easier configuration, fluent setter objects are available to define variants and variant file-handling steps. This avoids the need to know many details about the array syntax, and offers auto-completion in your IDE. 60 | 61 | The `\Czim\Paperclip\Config\Variant` class may be used to define any variant. 62 | The `\Czim\Paperclip\Config\Steps\AutoOrientStep` and `\Czim\Paperclip\Config\Steps\ResizeStep` classes may be used for fluent configuration of file-handling steps. 63 | 64 | Arrays and fluent models may be mixed and used interchangeably. 65 | 66 | ```php 67 | hasAttachedFile('image', [ 75 | 'variants' => [ 76 | 'thumb' => ResizeStep::make()->square(100), // = '100x100' 77 | Variant::make('landscape')->steps([ 78 | AutoOrientStep::make(), 79 | ResizeStep::make()->width(300), // = '300x' 80 | ]), 81 | Variant::make('portrait')->steps([ 82 | AutoOrientStep::make(), 83 | ResizeStep::make()->height(300), // = 'x300' 84 | ]), 85 | 'cropped' => [ 86 | AutoOrientStep::make(), 87 | ResizeStep::make()->width(150)->height(300)->crop(), // = '150x300#' 88 | ], 89 | 'ignored_aspect_ratio' => [ 90 | AutoOrientStep::make(), 91 | ResizeStep::make()->square(100)->ignoreRatio(), // = '100x100!' 92 | ], 93 | // The Variant class lets you set further properties 94 | Variant::make('some_variant') 95 | ->steps([ 96 | AutoOrientStep::make(), 97 | 'resize' => '100x100#', 98 | ]) 99 | ->url('http://domain.com/default/missing-image.jpg') 100 | ->extension('jpg'), 101 | ], 102 | ]); 103 | 104 | // ... 105 | ``` 106 | 107 | Note that using the `Variant` object allows you to set the (expected) extension and fallback URL directly for a specific variant, rather than setting it its relevant separate configuration array (see below for further information on extensions and fallback URLs). 108 | 109 | Advanced use: any `Arrayable` object may be used to define variant steps, provided the array output is compatible. Please refer to the code and tests for further information. 110 | 111 | 112 | ### Array configuration 113 | 114 | It is also possible to configure an attachment with just an array (the classic approach): 115 | 116 | ```php 117 | hasAttachedFile('image', [ 121 | 'variants' => [ 122 | 'thumb' => '100x100', 123 | 'landscape' => [ 124 | 'auto-orient' => [], 125 | 'resize' => [ 126 | 'dimensions' => '300x', 127 | ] 128 | ], 129 | ], 130 | ]); 131 | ``` 132 | 133 | 134 | ### Resizing: callable resizer and using imagine 135 | 136 | It is also possible to configure an attachment with just an array and using imagine for custom resizing: 137 | 138 | ```php 139 | hasAttachedFile('image', [ 151 | 'variants' => [ 152 | 'thumb' => '100x100', 153 | 'large' => [ 154 | 'auto-orient' => [], 155 | 'resize' => [ 156 | 'dimensions' => $this->resizeHandle(800, 600), 157 | ] 158 | ], 159 | ], 160 | ]); 161 | } 162 | ``` 163 | 164 | 165 | ### Fallback URLs for missing attachments 166 | 167 | When no image is stored for a given attachment, any `url()` calls will return `null`. 168 | It is possible to configure a fallback URL to return instead: 169 | 170 | ```php 171 | hasAttachedFile('image', [ 173 | 'variants' => [ 174 | 'thumb' => '100x100', 175 | ], 176 | // This URL is given for attachments with no stored file, for 'original', 177 | // and any variants that have no specific variant fallback URL set. 178 | 'url' => 'http://domain.com/missing_image.jpg', 179 | 'urls' => [ 180 | // This fallback URL is only given for the 'thumb' variant. 181 | 'thumb' => 'http://domain.com/missing_thumbnail_image.jpg', 182 | ], 183 | ]); 184 | ``` 185 | 186 | 187 | ### Indicating variant extension 188 | 189 | Usually variants will keep the original file extension. In some cases, however, you may want to convert files or derive files from originals with a different extension. 190 | 191 | In those cases, Paperclip will need to be able to match extensions to variants in order to generate the correct URLs and paths. This can be done in two ways: 192 | 193 | - The `extensions` key in the attachment configuration. 194 | If specific variants has an alternative extension, this may be indicated as follows. In this example, two variants will keep the same extension, but two custom variants would have a different extension. 195 | 196 | ```php 197 | [ 200 | 'medium' => '300x300', 201 | 'thumb' => '100x100', 202 | 'special' => [ 203 | 'derive-description-text' => [], 204 | ], 205 | 'converted' => [ 206 | 'convert-to-bmp' => [], 207 | ], 208 | ], 209 | 'extensions' => [ 210 | 'special' => 'txt', 211 | 'converted' => 'bmp', 212 | ], 213 | ]; 214 | ``` 215 | 216 | - The `variants` attribute on the parent model of the attachment. 217 | The configuration `extensions` approach above will require manually indicating the extensions per variant. 218 | This may be automated by enabling the `variants` attribute on the parent model. 219 | This is a text column with JSON-encoded information on actually processed variants, which will include the extension for the variant. 220 | Note that if a variant strategy may result in files with different extensions, this is the only way to allow Paperclip to reliably generate URLs to that variant. 221 | 222 | Note that when using the fluent object variant configuration, you may also set the extension on the variant object directly: 223 | 224 | ```php 225 | // .. 226 | [ 227 | new Variant::make('thumbnail') 228 | ->steps([ Resize::make()->square(64), /* ... */ ]) 229 | ->extension('png'), 230 | ] 231 | // .. 232 | ``` 233 | 234 | 235 | ### Before and After Processing Hooks 236 | 237 | To hook into the process of uploading paperclip attached files, set the `before` and/or `after` configuration keys for the attachment. 238 | This may be a `callable` anonymous function (not recommended for models that should be serializable!) or a string with a `ClassFQN@methodName` format. 239 | 240 | Examples: 241 | 242 | ```php 243 | hasAttachedFile('image', [ 247 | 'before' => function ($attachment) { /* Do something here */ }, 248 | 'after' => 'YourHook\HelperClass@yourMethodName', 249 | ]); 250 | 251 | // ... 252 | ``` 253 | 254 | The hook method that is called should expect one parameter, which is the current `Czim\Paperclip\Attachment\Attachment` instance being processed (type-hintable interface: `Czim\Paperclip\Attachment\AttachmentInterface`). 255 | 256 | 257 | ### Resize dimension syntax 258 | 259 | When using the array syntax to define resize `'dimensions'`, this takes the same syntax as Stapler did (Examples: `300x300`, `640x480!`, `x40`). 260 | 261 | [Refer this documentation](https://github.com/CodeSleeve/stapler/blob/master/docs/imageprocessing.md) for compatible examples. 262 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/czim/laravel-paperclip). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)**. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Create feature branches** - Don't ask us to pull from your master branch. 17 | 18 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 19 | 20 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Coen Zimmerman 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Version on Packagist][ico-version]][link-packagist] 2 | [![Software License][ico-license]](LICENSE.md) 3 | [![Build Status](https://travis-ci.com/czim/laravel-paperclip.svg?branch=master)](https://travis-ci.com/czim/laravel-paperclip) 4 | [![Coverage Status](https://coveralls.io/repos/github/czim/laravel-paperclip/badge.svg?branch=master)](https://coveralls.io/github/czim/laravel-paperclip?branch=master) 5 | 6 | # Laravel Paperclip: File Attachment Solution 7 | 8 | Allows you to attach files to Eloquent models. 9 | 10 | This is a re-take on [CodeSleeve's Stapler](https://github.com/CodeSleeve/stapler). 11 | It is mainly intended to be more reusable and easier to adapt to different Laravel versions. 12 | Despite the name, this should not be considered a match for Ruby's Paperclip gem. 13 | 14 | Instead of tackling file storage itself, it uses Laravel's internal storage drivers and configuration. 15 | 16 | This uses [czim/file-handling](https://github.com/czim/file-handling) under the hood, 17 | and any of its (and your custom written) variant manipulations may be used with this package. 18 | 19 | 20 | ## Version Compatibility 21 | 22 | | Laravel | Package | PHP Version | 23 | |:--------------|:---------|:--------------| 24 | | 5.4 and below | 1.0, 2.1 | 7.4 and below | 25 | | 5.5 | 1.5, 2.5 | 7.4 and below | 26 | | 5.6, 5.7 | 2.6 | 7.4 and below | 27 | | 5.8, 6 | 2.7 | 7.4 and below | 28 | | 7, 8 | 3.2 | 7.4 and below | 29 | | 7, 8, 9 | 4.0 | 8.0 and up | 30 | | 9 and up | 5.0 | 8.1 and up | 31 | 32 | 33 | ## Change log 34 | 35 | [View the changelog](CHANGELOG.md). 36 | 37 | 38 | ## Installation 39 | 40 | Via Composer: 41 | 42 | ``` bash 43 | $ composer require czim/laravel-paperclip 44 | ``` 45 | 46 | Auto-discover may be used to register the service provider automatically. 47 | Otherwise, you can manually register the service provider in `config/app.php`: 48 | 49 | ```php 50 | [ 52 | ... 53 | Czim\Paperclip\Providers\PaperclipServiceProvider::class, 54 | ... 55 | ], 56 | ``` 57 | 58 | Publish the configuration file: 59 | 60 | ``` bash 61 | php artisan vendor:publish --provider="Czim\Paperclip\Providers\PaperclipServiceProvider" 62 | ``` 63 | 64 | 65 | ## Set up and Configuration 66 | 67 | ### Model Preparation 68 | 69 | Modify the database to add some columns for the model that will get an attachment. 70 | Use the attachment key name as a prefix. 71 | 72 | An example migration: 73 | 74 | ```php 75 | string('attachmentname_file_name')->nullable(); 78 | $table->integer('attachmentname_file_size')->nullable(); 79 | $table->string('attachmentname_content_type')->nullable(); 80 | $table->timestamp('attachmentname_updated_at')->nullable(); 81 | }); 82 | ``` 83 | 84 | Replace `attachmentname` here with the name of the attachment. 85 | These attributes should be familiar if you've used Stapler before. 86 | 87 | A `_variants` text or varchar column is optional: 88 | 89 | ```php 90 | string('attachmentname_variants', 255)->nullable(); 92 | ``` 93 | 94 | A `text()` column is recommended in cases where a seriously *huge* amount of variants are created. 95 | 96 | If it is added and configured to be used (more on that [in the config section](CONFIG.md)), 97 | JSON information about variants will be stored in it. 98 | 99 | 100 | ### Attachment Configuration 101 | 102 | To add an attachment to a model: 103 | 104 | - Make it implement `Czim\Paperclip\Contracts\AttachableInterface`. 105 | - Make it use the `Czim\Paperclip\Model\PaperclipTrait`. 106 | - Configure attachments in the constructor (very similar to Stapler) 107 | 108 | ```php 109 | hasAttachedFile('image', [ 117 | 'variants' => [ 118 | 'medium' => [ 119 | 'auto-orient' => [], 120 | 'resize' => ['dimensions' => '300x300'], 121 | ], 122 | 'thumb' => '100x100', 123 | ], 124 | 'attributes' => [ 125 | 'variants' => true, 126 | ], 127 | ]); 128 | 129 | parent::__construct($attributes); 130 | } 131 | } 132 | ``` 133 | 134 | Note: If you perform the `hasAttachedFile()` call(s) *after* the `parent::__construct()` call, 135 | everything will work the same, except that you cannot assign an image directly when creating a model. 136 | `ModelClass::create(['attachment' => ...])` will not work in that case. 137 | 138 | 139 | Since version `2.5.7` it is also possible to use an easier to use fluent object syntax for defining variant steps: 140 | 141 | ```php 142 | hasAttachedFile('image', [ 150 | 'variants' => [ 151 | Variant::make('medium')->steps([ 152 | AutoOrientStep::make(), 153 | ResizeStep::make()->width(300)->height(150)->crop(), 154 | ]), 155 | Variant::make('medium')->steps(ResizeStep::make()->square(100)), 156 | ], 157 | ]); 158 | ``` 159 | 160 | 161 | ### Variant Configuration 162 | 163 | For the most part, the configuration of variants is nearly identical to Stapler, 164 | so it should be easy to make the transition either way. 165 | 166 | Since version `2.6`, Stapler configuration support is disabled by default, but legacy support for this 167 | may be enabled by setting the `paperclip.config.mode` to `'stapler'`. 168 | 169 | [Get more information on configuration here](CONFIG.md). 170 | 171 | 172 | #### Custom Variants 173 | 174 | The file handler comes with a few common variant strategies, including resizing images and taking screenshots from videos. 175 | It is easy, however, to add your own custom strategies to manipulate files in any way required. 176 | 177 | Variant processing is handled by [the file-handler package](https://github.com/czim/file-handling). 178 | Check out its source to get started writing custom variant strategies. 179 | 180 | 181 | ### Storage configuration 182 | 183 | You can configure a storage location for uploaded files by setting up a Laravel storage (in `config/filesystems.php`), 184 | and registering it in the `config/paperclip.php` config file. 185 | 186 | Make sure that `paperclip.storage.base-urls.` is set, so valid URLs to stored content are returned. 187 | 188 | ### Hooks Before and After Processing 189 | 190 | It is possible to 'hook' into the paperclip goings on when files are processed. This may be done by using the 191 | `before` and/or `after` configuration keys. Before hooks are called after the file is uploaded and stored locally, 192 | but before variants are processed; after hooks are called when all variants have been processed. 193 | 194 | More information and examples are in [the Config section](CONFIG.md). 195 | 196 | ### Events 197 | 198 | The following events are available: 199 | 200 | * `AttachmentSavedEvent`: dispatched when any attachment is saved with a file 201 | 202 | ### Refreshing models 203 | 204 | When changing variant configurations for models, you may reprocess variants from previously created attachments with the `paperclip:refresh` Artisan command. 205 | 206 | Example: 207 | 208 | ```bash 209 | php artisan paperclip:refresh "App\Models\BlogPost" --attachments header,background 210 | ``` 211 | 212 | 213 | ## Usage 214 | 215 | Once a model is set up and configured for an attachment, you can simply set the attachment attribute on that model to create an attachment. 216 | 217 | ```php 218 | attachmentname = $request->file('uploaded'); 226 | 227 | // Saving the model will then process and store the attachment. 228 | $model->save(); 229 | 230 | // ... 231 | } 232 | ``` 233 | 234 | 235 | ### Setting attachments without uploads 236 | 237 | Usually, you will want to set an uploaded file as an attachment. If you want to store a file from within your application, 238 | without the context of a request or a file upload, you can use the following approach: 239 | 240 | ```php 241 | attachmentname = new \SplFileInfo('local/path/to.file'); 244 | 245 | 246 | // Or a file-handler class that allows you to override values: 247 | $file = new \Czim\FileHandling\Storage\File\SplFileInfoStorableFile(); 248 | $file->setData(new \SplFileInfo('local/path/to.file')); 249 | // Optional, will be derived from the file normally 250 | $file->setMimeType('image/jpeg'); 251 | // Optional, the file's current name will be used normally 252 | $file->setName('original-file-name.jpg'); 253 | $model->attachmentname = $file; 254 | 255 | 256 | // Or even a class representing raw content 257 | $raw = new \Czim\FileHandling\Storage\File\RawStorableFile(); 258 | $raw->setData('... string with raw content of file ...'); 259 | $raw->setMimeType('image/jpeg'); 260 | $raw->setName('original-file-name.jpg'); 261 | $model->attachmentname = $raw; 262 | ``` 263 | 264 | 265 | ### Clearing attachments 266 | 267 | In order to prevent accidental deletion, setting the attachment to `null` will *not* destroy a previously stored attachment. 268 | Instead you have to explicitly destroy it. 269 | 270 | ```php 271 | attachmentname = \Czim\Paperclip\Attachment\Attachment::NULL_ATTACHMENT; 274 | // In version 2.5.5 and up, this value is configurable and available in the config: 275 | $model->attachmentname = config('paperclip.delete-hash'); 276 | 277 | // You can also directly clear the attachment by flagging it for deletion: 278 | $model->attachmentname->setToBeDeleted(); 279 | 280 | 281 | // After any of these approaches, saving the model will make the deletion take effect. 282 | $model->save(); 283 | ``` 284 | 285 | 286 | ## Differences with Stapler 287 | 288 | - Paperclip does not handle (s3) storage internally, as Stapler did. 289 | All storage is performed through Laravel's storage solution. 290 | You can still use S3 (or any other storage disk), but you will have to configure it in Laravel's storage configuration first. 291 | It is possible to use different storage disks for different attachments. 292 | 293 | - Paperclip *might* show slightly different behavior when storing a `string` value on the attachment attribute. 294 | It will attempt to interpret the string as a URI (or a dataURI), and otherwise treat the string as raw text file content. 295 | 296 | If you wish to force storing the contents of a URL without letting Paperclip interpret it, you have some options. 297 | You can use the `Czim\FileHandling\Storage\File\StorableFileFactory@makeFromUrl` method and its return value. 298 | Or, you can download the contents yourself and store them in a `Czim\FileHandling\Storage\File\RawStorableFile` 299 | (e.g.: `(new RawStorableFile)->setData(file_get_contents('your-URL-here'))`). 300 | You can also download the file to local disk, and store it on the model through an `\SplFileInfo` instance (see examples on the main readme page). 301 | 302 | - The `convert_options` configuration settings are no longer available. 303 | Conversion options are now handled at the level of the variant strategies. 304 | You can set them per attachment configuration, or modify the variant strategy to use a custom global configuration. 305 | 306 | - The refresh command (`php artisan paperclip:refresh`) is very similar to stapler's refresh command, 307 | - but it can optionally take a `--start #` and/or `--stop #` option, with ID numbers. 308 | This makes it possible to refresh only a subset of models. 309 | Under the hood, the refresh command is also much less likely to run out of memory (it uses a generator to process models in chunks). 310 | 311 | - The Paperclip trait uses its own Eloquent boot method, not the global Model's `boot()`. 312 | This makes Paperclip less likely to conflict with other traits and model implementations. 313 | 314 | 315 | ## Amazon S3 cache-control 316 | 317 | If you use Amazon S3 as storage disk for your attachments, note that you can set `Cache-Control` headers in the options for the `filesystems.disks.s3` configuration key. 318 | For example, to set `max-age` headers on all uploaded files to S3, edit `config/filesystems.php` like so: 319 | 320 | ``` 321 | 's3' => [ 322 | 'driver' => env('S3_DRIVER', 's3'), 323 | 'key' => env('S3_KEY', 'your-key'), 324 | 'secret' => env('S3_SECRET', 'your-secret'), 325 | 'region' => env('S3_REGION', 'your-region'), 326 | 'bucket' => env('S3_BUCKET', 'your-bucket'), 327 | 'visibility' => 'public', 328 | 'options' => [ 329 | 'CacheControl' => 'max-age=315360000, no-transform, public', 330 | ], 331 | ], 332 | ``` 333 | 334 | ## Upgrade Guide 335 | 336 | ### Upgrading from 1.5.* to 2.5.* 337 | 338 | **Estimated Upgrade Time: 5 - 10 Minutes** 339 | 340 | ### Updating Dependencies 341 | 342 | Update your `czim/laravel-paperclip` dependency to `^2.5` in your `composer.json` file. 343 | 344 | ``` 345 | "require": { 346 | ... 347 | "czim/laravel-paperclip": "^2.5", 348 | ... 349 | } 350 | ``` 351 | 352 | Then, in your terminal run: 353 | 354 | ``` 355 | composer update czim/laravel-paperclip --with-dependencies 356 | ``` 357 | 358 | In addition, if you are using the `czim/file-handling` package directly, you should upgrade the package 359 | to its `^1,0` release, but be sure to checkout the [CHANGELOG](https://github.com/czim/file-handling/blob/master/CHANGELOG.md) 360 | 361 | ``` 362 | "require": { 363 | ... 364 | "czim/file-handling": "^1.0", 365 | ... 366 | } 367 | ``` 368 | 369 | ### Update Configuration 370 | 371 | Update your `config/paperclip.php` file and replace: 372 | 373 | ``` 374 | // The base path that the interpolator should use 375 | 'base-path' => ':class/:id_partition/:attribute', 376 | ``` 377 | 378 | With: 379 | 380 | ``` 381 | // The path to the original file to be interpolated. This will also\ 382 | // be used for variant paths if the variant key is unset. 383 | 'original' => ':class/:id_partition/:attribute/:variant/:filename', 384 | 385 | // If the structure for variant filenames should differ from the 386 | // original, it may be defined here. 387 | 'variant' => null, 388 | ``` 389 | 390 | This should now include placeholders to make a full file path including the filename, as opposed to only a directory. 391 | Note that this makes the path interpolation logic more in line with the way Stapler handled it. 392 | 393 | 394 | ## Contributing 395 | 396 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 397 | 398 | 399 | ## Credits 400 | 401 | - [All Contributors][link-contributors] 402 | 403 | ## License 404 | 405 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 406 | 407 | [ico-version]: https://img.shields.io/packagist/v/czim/laravel-paperclip.svg?style=flat-square 408 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 409 | [ico-downloads]: https://img.shields.io/packagist/dt/czim/laravel-paperclip.svg?style=flat-square 410 | 411 | [link-packagist]: https://packagist.org/packages/czim/laravel-paperclip 412 | [link-downloads]: https://packagist.org/packages/czim/laravel-paperclip 413 | [link-author]: https://github.com/czim 414 | [link-contributors]: ../../contributors 415 | -------------------------------------------------------------------------------- /STAPLER.md: -------------------------------------------------------------------------------- 1 | # Stapler compatibility 2 | 3 | This package works very much like [CodeSleeve's Stapler](https://github.com/CodeSleeve/stapler) did. 4 | It is even possible to allow (almost) exactly the same configuration arrays to be used with Paperclip as they worked in Stapler. 5 | 6 | To enable this functionality, set the `paperclip.config.mode` to `'stapler'`. 7 | 8 | For example, if so configured, the following configuration works as expected: 9 | 10 | ```php 11 | hasAttachedFile('image', [ 15 | 'styles' => [ 16 | 'medium' => [ 17 | 'dimensions' => '300x300', 18 | 'auto_orient' => true, 19 | ], 20 | 'thumb' => '100x100', 21 | ], 22 | ]); 23 | 24 | // ... 25 | ``` 26 | 27 | This will be internally normalized to auto-orient a medium-sized image, and resize a thumb-sized image (without orienting). 28 | 29 | This stapler configuration is functionally identical to this paperclip configuration: 30 | 31 | ```php 32 | hasAttachedFile('image', [ 40 | 'variants' => [ 41 | Variant::make('medium') 42 | ->steps([ 43 | AutoOrientStep::make(), 44 | ResizeStep::make() 45 | ->square(300), 46 | ]), 47 | Variant::make('thumb')->steps( 48 | ResizeStep::make()->square(100) 49 | ) 50 | ], 51 | ]); 52 | ``` 53 | 54 | 55 | ## Defining the default fallback URL for when no attachment is et. 56 | 57 | In Stapler, the `'url'` array key refers to what is `'path'` does in Paperclip. 58 | 59 | To set the default fallback URL (used for the `original` version of the attachment, or any variant that has no specific fallback URL set), use the `'missing_url'` array key: 60 | 61 | ```php 62 | hasAttachedFile('image', [ 64 | 'styles' => [ 65 | 'thumb' => '100x100', 66 | ], 67 | 'missing_url' => 'http://domain.com/missing_image.jpg', 68 | 'urls' => [ 69 | 'thumb' => 'http://domain.com/missing_image_thumb.jpg', 70 | ], 71 | ]); 72 | ``` 73 | 74 | Note that the global default for a missing image can not be set (as it could in Stapler); it was not possible in Stapler to set variant-specific fallback URLs, so the `'urls'` key has no Stapler-equivalent. 75 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "czim/laravel-paperclip", 3 | "description": "Laravel Eloquent file attachment solution", 4 | "keywords": [ "file", "attachment", "laravel", "upload" ], 5 | "homepage": "https://github.com/czim", 6 | "require": { 7 | "php": "^8.1", 8 | "czim/file-handling": "^2.3", 9 | "ext-json": "*" 10 | }, 11 | "require-dev": { 12 | "mockery/mockery": "^1.5", 13 | "nunomaduro/larastan": "^2.5", 14 | "phpstan/phpstan-mockery": "^1.1", 15 | "phpstan/phpstan-phpunit": "^1.1", 16 | "phpunit/phpunit": "^10.0", 17 | "orchestra/testbench": "^8.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Czim\\Paperclip\\": "src" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "Czim\\Paperclip\\Test\\": "tests" 27 | } 28 | }, 29 | "scripts": { 30 | "test": "phpunit" 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Czim\\Paperclip\\Providers\\PaperclipServiceProvider" 36 | ] 37 | } 38 | }, 39 | "minimum-stability": "dev", 40 | "prefer-stable": true 41 | } 42 | -------------------------------------------------------------------------------- /config/paperclip.php: -------------------------------------------------------------------------------- 1 | [ 17 | // Available modes: 'paperclip' (default), 'stapler' 18 | 'mode' => 'paperclip', 19 | ], 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Model 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Configure how attached file information in stored on attachables. 27 | | 28 | */ 29 | 30 | 'model' => [ 31 | 32 | // Mark which columns should be filled on the model by default. 33 | // These attributes are prefixed by _. 34 | 'attributes' => [ 35 | 'size' => true, 36 | 'content_type' => true, 37 | 'updated_at' => true, 38 | 'created_at' => false, 39 | // JSON information about stored variants. 40 | 'variants' => false, 41 | ], 42 | 43 | ], 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Storage 48 | |-------------------------------------------------------------------------- 49 | | 50 | | Settings for handling storage of uploaded files. 51 | | 52 | */ 53 | 54 | 'storage' => [ 55 | 56 | // The Laravel storage disk to use. 57 | 'disk' => 'paperclip', 58 | 59 | // Per disk, the base URL where attachments are stored at. If 'url' is set for the disk, this is not required. 60 | 'base-urls' => [ 61 | 'paperclip' => config('app.url') . '/paperclip', 62 | ], 63 | ], 64 | 65 | /* 66 | |-------------------------------------------------------------------------- 67 | | Path 68 | |-------------------------------------------------------------------------- 69 | | 70 | | The storage path that uploaded files and variants for models are placed in. 71 | | 72 | */ 73 | 74 | 'path' => [ 75 | 76 | // The class that generates the paths 77 | 'interpolator' => \Czim\Paperclip\Path\Interpolator::class, 78 | 79 | // The path to the original file to be interpolated. This will also\ 80 | // be used for variant paths if the variant key is unset. 81 | 'original' => ':class/:id_partition/:attribute/:variant/:filename', 82 | 83 | // If the structure for variant filenames should differ from the 84 | // original, it may be defined here. 85 | 'variant' => null, 86 | ], 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Variants 91 | |-------------------------------------------------------------------------- 92 | | 93 | | Processed files may have any number of variants: versions of the file that 94 | | are resized, rotated, compressed, or whatever you can think of. 95 | | 96 | */ 97 | 98 | // The default to use for the main URL 99 | 'default-variant' => 'original', 100 | 101 | // Variant processing configuration 102 | 'variants' => [ 103 | 104 | 'aliases' => [ 105 | 'auto-orient' => \Czim\FileHandling\Variant\Strategies\ImageAutoOrientStrategy::class, 106 | 'resize' => \Czim\FileHandling\Variant\Strategies\ImageResizeStrategy::class, 107 | ], 108 | 109 | // Set this to true to always merge in the default variants into any attachment configuration. 110 | // False only sets defaults if no variants are configured for an attachment; 111 | // true always merges them in (not overriding specifics by variant name). 112 | // 113 | // When this is enabled, it is possible to 'disable' the default variants by setting 114 | // the attachment configuration to `false` (instead of an array with steps). 115 | 'merge-default' => false, 116 | 117 | // If no specific variants are set for a clipped file on a Model, these 118 | // variant definitions will be used. 119 | 'default' => [ 120 | 121 | // Fluent object format is allowed: 122 | // \Czim\Paperclip\Config\Steps\ResizeStep::make('variant-name')->square(50)->crop(), 123 | 124 | // Classic array format is allowed: 125 | // 'variantname' => [ 126 | // 'strategy-alias' => [ 'strategy' => 'configuration' ], 127 | // ], 128 | ], 129 | ], 130 | 131 | /* 132 | |-------------------------------------------------------------------------- 133 | | File Preservation 134 | |-------------------------------------------------------------------------- 135 | | 136 | | Settings to affect when attachment files are destroyed. 137 | | 138 | */ 139 | 140 | // Set this to true in order to prevent older file uploads from being deleted. 141 | 'keep-old-files' => false, 142 | 143 | // Set this to true in order to prevent file uploads from being deleted as attachments are destroyed. 144 | 'preserve-files' => false, 145 | 146 | // A string value that, when set on an attachment property, will delete the attachment. 147 | 'delete-hash' => Czim\Paperclip\Attachment\Attachment::NULL_ATTACHMENT, 148 | 149 | /* 150 | |-------------------------------------------------------------------------- 151 | | Imagine 152 | |-------------------------------------------------------------------------- 153 | | 154 | | The default binding to use for the ImagineInterface. May be Gd or Imagick. 155 | | 156 | */ 157 | 158 | 'imagine' => Imagine\Gd\Imagine::class, 159 | 160 | /* 161 | |-------------------------------------------------------------------------- 162 | | Processing 163 | |-------------------------------------------------------------------------- 164 | | 165 | | Settings for (re)processing attachments. 166 | | 167 | */ 168 | 169 | 'processing' => [ 170 | 'chunk-size' => 500, 171 | 172 | // Handling of errors during processing. 173 | 'errors' => [ 174 | // Whether to fire exception events, rather than throw exceptions 175 | // This prevents processing from halting on 176 | 'event' => true, 177 | ], 178 | ], 179 | 180 | /* 181 | |-------------------------------------------------------------------------- 182 | | Date Time Formatting 183 | |-------------------------------------------------------------------------- 184 | */ 185 | 186 | 'datetime' => [ 187 | 'format' => 'c', 188 | ], 189 | ]; 190 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | - ./vendor/phpstan/phpstan-phpunit/extension.neon 4 | - ./vendor/phpstan/phpstan-mockery/extension.neon 5 | 6 | parameters: 7 | paths: 8 | - src 9 | - tests 10 | excludePaths: 11 | - tests\Helpers\* 12 | - tests\resources\* 13 | ignoreErrors: 14 | - 15 | message: '#Generator expects value type Illuminate\\Database\\Eloquent\\Collection#' 16 | path: src/Console/Commands/RefreshAttachmentCommand.php 17 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./src/ 14 | 15 | 16 | 17 | 18 | 19 | ./tests/ 20 | ./tests/Helpers 21 | ./tests/ProvisionedTestCase.php 22 | ./tests/TestCase.php 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Attachment/AttachmentData.php: -------------------------------------------------------------------------------- 1 | $config 20 | * @param array $attributes 21 | * @param array> $variants 22 | * @param mixed $instanceKey 23 | * @param class-string $instanceClass 24 | */ 25 | public function __construct( 26 | protected readonly string $name, 27 | protected readonly array $config, 28 | protected readonly array $attributes, 29 | protected readonly array $variants, 30 | protected readonly mixed $instanceKey, 31 | protected readonly string $instanceClass, 32 | ) { 33 | } 34 | 35 | public function name(): string 36 | { 37 | return $this->name; 38 | } 39 | 40 | /** 41 | * {@inheritDoc} 42 | */ 43 | public function getConfig(): array 44 | { 45 | return $this->config; 46 | } 47 | 48 | /** 49 | * {@inheritDoc} 50 | */ 51 | public function createdAt(): ?string 52 | { 53 | if (! array_key_exists('created_at', $this->attributes)) { 54 | return null; 55 | } 56 | 57 | return $this->attributes['created_at']; 58 | } 59 | 60 | /** 61 | * {@inheritDoc} 62 | */ 63 | public function updatedAt(): ?string 64 | { 65 | if (! array_key_exists('updated_at', $this->attributes)) { 66 | return null; 67 | } 68 | 69 | return $this->attributes['updated_at']; 70 | } 71 | 72 | /** 73 | * {@inheritDoc} 74 | */ 75 | public function contentType(): ?string 76 | { 77 | if (! array_key_exists('content_type', $this->attributes)) { 78 | return null; 79 | } 80 | 81 | return $this->attributes['content_type']; 82 | } 83 | 84 | /** 85 | * {@inheritDoc} 86 | */ 87 | public function size(): ?int 88 | { 89 | if (! array_key_exists('file_size', $this->attributes)) { 90 | return null; 91 | } 92 | 93 | return $this->attributes['file_size']; 94 | } 95 | 96 | /** 97 | * {@inheritDoc} 98 | */ 99 | public function originalFilename(): ?string 100 | { 101 | if (! array_key_exists('file_name', $this->attributes)) { 102 | return null; 103 | } 104 | 105 | return $this->attributes['file_name']; 106 | } 107 | 108 | /** 109 | * {@inheritDoc} 110 | */ 111 | public function variantsAttribute(): array 112 | { 113 | if (! array_key_exists('variants', $this->attributes)) { 114 | return []; 115 | } 116 | 117 | return $this->attributes['variants']; 118 | } 119 | 120 | /** 121 | * {@inheritDoc} 122 | */ 123 | public function variantFilename(?string $variant): string|false 124 | { 125 | if ( 126 | ! array_key_exists($variant, $this->variants) 127 | || ! array_key_exists('file_name', $this->variants[ $variant ]) 128 | ) { 129 | return false; 130 | } 131 | 132 | return $this->variants[ $variant ]['file_name']; 133 | } 134 | 135 | /** 136 | * {@inheritDoc} 137 | */ 138 | public function variantExtension(string $variant): string|false 139 | { 140 | if ( 141 | ! array_key_exists($variant, $this->variants) 142 | || ! array_key_exists('extension', $this->variants[ $variant ]) 143 | ) { 144 | return false; 145 | } 146 | 147 | return $this->variants[ $variant ]['extension']; 148 | } 149 | 150 | /** 151 | * {@inheritDoc} 152 | */ 153 | public function variantContentType(string $variant): string|false 154 | { 155 | if ( 156 | ! array_key_exists($variant, $this->variants) 157 | || ! array_key_exists('content_type', $this->variants[ $variant ]) 158 | ) { 159 | return false; 160 | } 161 | 162 | return $this->variants[ $variant ]['content_type']; 163 | } 164 | 165 | /** 166 | * {@inheritDoc} 167 | */ 168 | public function getInstanceKey(): mixed 169 | { 170 | return $this->instanceKey; 171 | } 172 | 173 | /** 174 | * {@inheritDoc} 175 | */ 176 | public function getInstanceClass(): string 177 | { 178 | return $this->instanceClass; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Attachment/AttachmentFactory.php: -------------------------------------------------------------------------------- 1 | $config 22 | * @return AttachmentInterface 23 | */ 24 | public function create(AttachableInterface $instance, string $name, array $config = []): AttachmentInterface 25 | { 26 | $attachment = $this->createInstance(); 27 | 28 | $configObject = $this->makeConfigObject($config); 29 | 30 | $attachment->setInstance($instance); 31 | $attachment->setName($name); 32 | $attachment->setConfig($configObject); 33 | $attachment->setInterpolator($this->getInterpolator()); 34 | $attachment->setStorage($configObject->storageDisk()); 35 | 36 | return $attachment; 37 | } 38 | 39 | protected function createInstance(): AttachmentInterface 40 | { 41 | return new Attachment(); 42 | } 43 | 44 | /** 45 | * @param array $config 46 | * @return ConfigInterface 47 | */ 48 | protected function makeConfigObject(array $config): ConfigInterface 49 | { 50 | if ($this->isStaplerConfigMode()) { 51 | return new StaplerConfig($config); 52 | } 53 | 54 | return new PaperclipConfig($config); 55 | } 56 | 57 | protected function isStaplerConfigMode(): bool 58 | { 59 | return strtolower(config('paperclip.config.mode') ?: '') === 'stapler'; 60 | } 61 | 62 | protected function getInterpolator(): InterpolatorInterface 63 | { 64 | $interpolatorClass = config('paperclip.path.interpolator', InterpolatorInterface::class); 65 | 66 | return app($interpolatorClass); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Config/AbstractConfig.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected array $normalizedConfig; 17 | 18 | 19 | /** 20 | * @param array $config 21 | */ 22 | public function __construct(protected array $config) 23 | { 24 | $this->normalizedConfig = $this->normalizeConfig($config); 25 | } 26 | 27 | 28 | public function keepOldFiles(): bool 29 | { 30 | return (bool) $this->getConfigValue('keep-old-files', false); 31 | } 32 | 33 | public function preserveFiles(): bool 34 | { 35 | return (bool) $this->getConfigValue('preserve-files', false); 36 | } 37 | 38 | public function storageDisk(): ?string 39 | { 40 | return $this->getConfigValue('storage'); 41 | } 42 | 43 | public function path(): string 44 | { 45 | return $this->getConfigValue('path'); 46 | } 47 | 48 | public function variantPath(): ?string 49 | { 50 | return $this->getConfigValue('variant-path'); 51 | } 52 | 53 | public function sizeAttribute(): string|bool 54 | { 55 | return $this->getConfigValue('attributes.size'); 56 | } 57 | 58 | public function contentTypeAttribute(): string|bool 59 | { 60 | return $this->getConfigValue('attributes.content_type'); 61 | } 62 | 63 | public function updatedAtAttribute(): string|bool 64 | { 65 | return $this->getConfigValue('attributes.updated_at'); 66 | } 67 | 68 | public function createdAtAttribute(): string|bool 69 | { 70 | return $this->getConfigValue('attributes.created_at'); 71 | } 72 | 73 | public function variantsAttribute(): string|bool 74 | { 75 | return $this->getConfigValue('attributes.variants'); 76 | } 77 | 78 | /** 79 | * Returns whether a configuration for a specific variant has been set. 80 | * 81 | * @param string $variant 82 | * @return bool 83 | */ 84 | public function hasVariantConfig(string $variant): bool 85 | { 86 | return Arr::has($this->variantConfigs(), $variant); 87 | } 88 | 89 | /** 90 | * Returns the configuration array for a specific variant. 91 | * 92 | * @param string $variant 93 | * @return array 94 | */ 95 | public function variantConfig(string $variant): array 96 | { 97 | return Arr::get($this->variantConfigs(), $variant, []); 98 | } 99 | 100 | /** 101 | * Returns an array with the variant configurations set. 102 | * 103 | * @return array> keyed by variant name 104 | */ 105 | public function variantConfigs(): array 106 | { 107 | return Arr::get($this->normalizedConfig, FileHandler::CONFIG_VARIANTS); 108 | } 109 | 110 | /** 111 | * Returns the mimetype specifically configured for a given variant. 112 | * 113 | * @param string $variant 114 | * @return string|false 115 | */ 116 | public function variantMimeType(string $variant): string|false 117 | { 118 | return $this->getConfigValue("types.{$variant}") ?: false; 119 | } 120 | 121 | /** 122 | * Returns an array with extensions configured per variant. 123 | * 124 | * @return array keyed by variant name 125 | */ 126 | public function variantExtensions(): array 127 | { 128 | return $this->getConfigValue('extensions', []); 129 | } 130 | 131 | /** 132 | * Returns the extension specifically configured for a given variant. 133 | * 134 | * Note that this does not determine the extension for the variant, 135 | * just what extension Paperclip should expect the created file to have. 136 | * 137 | * @param string $variant 138 | * @return string|false 139 | */ 140 | public function variantExtension(string $variant): string|false 141 | { 142 | return $this->getConfigValue("extensions.{$variant}") ?: false; 143 | } 144 | 145 | /** 146 | * Returns the default URL to use when no attachment is stored. 147 | * 148 | * @return string|null 149 | */ 150 | public function defaultUrl(): ?string 151 | { 152 | $defaultVariant = $this->getDefaultUrlVariantName(); 153 | 154 | return $this->getConfigValue('url', $this->defaultVariantUrl($defaultVariant)); 155 | } 156 | 157 | /** 158 | * Returns the default URL for a variant to use when no attachment is stored. 159 | * 160 | * @param string $variant 161 | * @return string|null 162 | */ 163 | public function defaultVariantUrl(string $variant): ?string 164 | { 165 | $defaultVariant = $this->getDefaultUrlVariantName(); 166 | 167 | return $this->getConfigValue( 168 | "urls.{$variant}", 169 | $variant === $defaultVariant 170 | ? $this->getDefaultUrlWithoutVariantFallback() 171 | : null 172 | ); 173 | } 174 | 175 | /** 176 | * Returns whether a given attribute property should be saved. 177 | * 178 | * Expected values: 'created_at', 'content_type', ... 179 | * 180 | * @param string $attribute 181 | * @return bool 182 | */ 183 | public function attributeProperty(string $attribute): bool 184 | { 185 | return (bool) $this->getConfigValue("attributes.{$attribute}", true); 186 | } 187 | 188 | /** 189 | * Returns the hook callable to run before storing an attachment. 190 | * 191 | * @return callable|callable-string|null 192 | */ 193 | public function beforeCallable(): callable|string|null 194 | { 195 | return $this->getConfigValue('before'); 196 | } 197 | 198 | /** 199 | * Returns the hook callable to run after storing an attachment. 200 | * 201 | * @return callable|callable-string|null 202 | */ 203 | public function afterCallable(): callable|string|null 204 | { 205 | return $this->getConfigValue('after'); 206 | } 207 | 208 | /** 209 | * @return array 210 | */ 211 | public function getOriginalConfig(): array 212 | { 213 | return $this->config; 214 | } 215 | 216 | /** 217 | * @return array 218 | */ 219 | public function toArray(): array 220 | { 221 | return $this->normalizedConfig; 222 | } 223 | 224 | 225 | /** 226 | * Takes the set config and creates a normalized version. 227 | * 228 | * @param array $config 229 | * @return array 230 | */ 231 | abstract protected function normalizeConfig(array $config): array; 232 | 233 | 234 | /** 235 | * Returns the config value relevant for this attachment. 236 | * 237 | * @param string $key 238 | * @param mixed $default 239 | * @return mixed 240 | */ 241 | protected function getConfigValue(string $key, mixed $default = null): mixed 242 | { 243 | if (Arr::has($this->normalizedConfig, $key)) { 244 | return Arr::get($this->normalizedConfig, $key); 245 | } 246 | 247 | return $this->getFallbackConfigValue($key, $default); 248 | } 249 | 250 | /** 251 | * Returns the global configuration fallback for a config value. 252 | * 253 | * @param string $key 254 | * @param mixed $default 255 | * @return mixed 256 | */ 257 | protected function getFallbackConfigValue(string $key, mixed $default = null): mixed 258 | { 259 | $map = [ 260 | 'keep-old-files' => 'keep-old-files', 261 | 'preserve-files' => 'preserve-files', 262 | 'storage' => 'storage.disk', 263 | 'path' => 'path.original', 264 | 'variant-path' => 'path.variant', 265 | 266 | 'attributes.size' => 'model.attributes.size', 267 | 'attributes.content_type' => 'model.attributes.content_type', 268 | 'attributes.updated_at' => 'model.attributes.updated_at', 269 | 'attributes.created_at' => 'model.attributes.created_at', 270 | 'attributes.variants' => 'model.attributes.variants', 271 | ]; 272 | 273 | if (! in_array($key, array_keys($map))) { 274 | return $default; 275 | } 276 | 277 | return config('paperclip.' . $map[ $key ], $default); 278 | } 279 | 280 | protected function getDefaultUrlWithoutVariantFallback(): ?string 281 | { 282 | return $this->getConfigValue('url'); 283 | } 284 | 285 | /** 286 | * Returns the variant name to consider default ('original') for URL generation. 287 | * 288 | * @return string 289 | */ 290 | protected function getDefaultUrlVariantName(): string 291 | { 292 | return config('paperclip.default-variant', 'original'); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/Config/PaperclipConfig.php: -------------------------------------------------------------------------------- 1 | castVariantsToVariantList(Arr::get($config, 'variants', [])); 19 | 20 | if ( ! $hasVariantsConfigured || $this->shouldMergeDefaultVariants()) { 21 | $variantList->mergeDefault(config('paperclip.variants.default', [])); 22 | } 23 | 24 | $extensions = $variantList->extensions(); 25 | $urls = $variantList->urls(); 26 | 27 | Arr::set($config, 'variants', $variantList->variants()); 28 | 29 | 30 | // Merge in extensions set through indirect means. 31 | if (count($extensions)) { 32 | Arr::set( 33 | $config, 34 | 'extensions', 35 | array_merge(Arr::get($config, 'extensions', []), $extensions) 36 | ); 37 | } 38 | 39 | // Merge in default URLs set through indirect means. 40 | if (count($urls)) { 41 | Arr::set( 42 | $config, 43 | 'urls', 44 | array_merge(Arr::get($config, 'urls', []), $urls) 45 | ); 46 | } 47 | 48 | return $config; 49 | } 50 | 51 | /** 52 | * @param array $variants 53 | * @return VariantList 54 | */ 55 | protected function castVariantsToVariantList(array $variants): VariantList 56 | { 57 | return new VariantList($variants); 58 | } 59 | 60 | /** 61 | * Returns whether default configured variants should always be merged in. 62 | * 63 | * @return bool 64 | */ 65 | protected function shouldMergeDefaultVariants(): bool 66 | { 67 | return (bool) config('paperclip.variants.merge-default'); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/Config/StaplerConfig.php: -------------------------------------------------------------------------------- 1 | $config 17 | * @return array 18 | */ 19 | protected function normalizeConfig(array $config): array 20 | { 21 | // In Stapler, variants were called 'styles'. 22 | if (! Arr::has($config, 'variants') && Arr::has($config, 'styles')) { 23 | $config['variants'] = Arr::get($config, 'styles', []); 24 | } 25 | Arr::forget($config, 'styles'); 26 | 27 | 28 | // Simple renames of stapler config keys. 29 | $renames = [ 30 | 'url' => 'path', 31 | 'keep_old_files' => 'keep-old-files', 32 | 'preserve_files' => 'preserve-files', 33 | 34 | // Note that 'url' conflicts with with Paperclip. 35 | // In Stapler, 'url' is what is 'path' in Paperclip. 36 | // To allow for full configuration 'missing_url' is mapped to 'url', 37 | // even though this option was not present in Stapler. 38 | 'missing_url' => 'url', 39 | ]; 40 | 41 | foreach ($renames as $old => $new) { 42 | if (! Arr::has($config, $old)) { 43 | continue; 44 | } 45 | 46 | if (! Arr::has($config, $new)) { 47 | $config[ $new ] = Arr::get($config, $old); 48 | } 49 | Arr::forget($config, $old); 50 | } 51 | 52 | return parent::normalizeConfig($config); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Config/Steps/AutoOrientStep.php: -------------------------------------------------------------------------------- 1 | quiet = true; 19 | 20 | return $this; 21 | } 22 | 23 | /** 24 | * @return array 25 | */ 26 | protected function getStepOptionArray(): array 27 | { 28 | return [ 29 | 'quiet' => $this->quiet, 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Config/Steps/ResizeStep.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected array $convertOptions = []; 22 | 23 | 24 | /** 25 | * @param int $pixels 26 | * @return $this 27 | */ 28 | public function width(int $pixels): static 29 | { 30 | $this->width = $pixels; 31 | 32 | return $this; 33 | } 34 | 35 | public function getWidth(): ?int 36 | { 37 | return $this->width; 38 | } 39 | 40 | /** 41 | * @param int $pixels 42 | * @return $this 43 | */ 44 | public function height(int $pixels): static 45 | { 46 | $this->height = $pixels; 47 | 48 | return $this; 49 | } 50 | 51 | public function getHeight(): ?int 52 | { 53 | return $this->height; 54 | } 55 | 56 | /** 57 | * @param int $pixels 58 | * @return $this 59 | */ 60 | public function square(int $pixels): static 61 | { 62 | $this->width = $this->height = $pixels; 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * @return $this 69 | */ 70 | public function crop(): static 71 | { 72 | $this->crop = true; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * @return $this 79 | */ 80 | public function ignoreRatio(): static 81 | { 82 | $this->ignoreRatio = true; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @param array $options 89 | * @return $this 90 | */ 91 | public function convertOptions(array $options): static 92 | { 93 | $this->convertOptions = $options; 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * {@inheritDoc} 100 | */ 101 | protected function getStepOptionArray(): array 102 | { 103 | return [ 104 | 'dimensions' => $this->compileDimensionsString(), 105 | 'convertOptions' => $this->convertOptions, 106 | ]; 107 | } 108 | 109 | 110 | protected function compileDimensionsString(): string 111 | { 112 | // If neither width nor height are set, the configuration is incomplete. 113 | if (! $this->width && ! $this->height) { 114 | throw new BadMethodCallException('Either width or height must be set'); 115 | } 116 | 117 | // If width or height is not set, the crop or ignore-ratio option are not available. 118 | if ( 119 | ! ($this->width && $this->height) 120 | && ($this->crop || $this->ignoreRatio) 121 | ) { 122 | throw new BadMethodCallException( 123 | "Cannot use 'crop' or 'ignoreRatio' unless both width and height are set" 124 | ); 125 | } 126 | 127 | // Crop and ignore-ratio conflict. 128 | if ($this->crop && $this->ignoreRatio) { 129 | throw new BadMethodCallException( 130 | "Only one of 'crop' and 'ignoreRatio' can be used" 131 | ); 132 | } 133 | 134 | return ($this->width ?: '') 135 | . 'x' 136 | . ($this->height ?: '') 137 | . ($this->crop ? '#' : '') 138 | . ($this->ignoreRatio ? '!' : ''); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Config/Steps/VariantStep.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class VariantStep implements Arrayable 14 | { 15 | protected string $name; 16 | protected string $defaultName = 'variant'; 17 | 18 | 19 | public function __construct(?string $name = null) 20 | { 21 | $this->name = $name ?: $this->defaultName; 22 | } 23 | 24 | /** 25 | * @param string|null $name 26 | * @return static 27 | */ 28 | public static function make(?string $name = null): static 29 | { 30 | return new static($name); 31 | } 32 | 33 | /** 34 | * @return array 35 | */ 36 | public function toArray(): array 37 | { 38 | return [ 39 | $this->name => $this->getStepOptionArray(), 40 | ]; 41 | } 42 | 43 | /** 44 | * @return array 45 | * @codeCoverageIgnore 46 | */ 47 | protected function getStepOptionArray(): array 48 | { 49 | return []; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Config/Variant.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $steps = []; 20 | 21 | /** 22 | * The extension that the variant's file is expected to be stored with. 23 | * 24 | * @var string|null 25 | */ 26 | protected ?string $extension = null; 27 | 28 | /** 29 | * Fallback-URL to use when no attachment is stored. 30 | * 31 | * @var string|null 32 | */ 33 | protected ?string $url = null; 34 | 35 | 36 | public function __construct(protected readonly string $name) 37 | { 38 | } 39 | 40 | public static function make(string $name): static 41 | { 42 | return new static($name); 43 | } 44 | 45 | /** 46 | * Sets variant processing steps. 47 | * 48 | * @param array|string|VariantStep $steps 49 | * @return $this 50 | */ 51 | public function steps(array|string|VariantStep $steps): static 52 | { 53 | if (! is_array($steps)) { 54 | $steps = [ $steps ]; 55 | } 56 | 57 | $this->steps = $steps; 58 | 59 | return $this; 60 | } 61 | 62 | /** 63 | * The filename extension to use. 64 | * 65 | * @param string|null $extension 66 | * @return $this 67 | */ 68 | public function extension(?string $extension): static 69 | { 70 | if (is_string($extension)) { 71 | $this->extension = ltrim($extension, '.'); 72 | } else { 73 | $this->extension = null; 74 | } 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Sets the fallback URL to use when the attachment is not stored. 81 | * 82 | * @param string $url 83 | * @return $this 84 | */ 85 | public function url(string $url): static 86 | { 87 | $this->url = $url; 88 | 89 | return $this; 90 | } 91 | 92 | public function getName(): string 93 | { 94 | return $this->name; 95 | } 96 | 97 | /** 98 | * @return array 99 | */ 100 | public function getSteps(): array 101 | { 102 | return $this->steps; 103 | } 104 | 105 | public function getExtension(): ?string 106 | { 107 | return $this->extension; 108 | } 109 | 110 | public function getUrl(): ?string 111 | { 112 | return $this->url; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Config/VariantList.php: -------------------------------------------------------------------------------- 1 | > by variant name 16 | */ 17 | protected array $variants = []; 18 | 19 | /** 20 | * @var array by variant name 21 | */ 22 | protected array $extensions = []; 23 | 24 | /** 25 | * @var array by variant name 26 | */ 27 | protected array $urls = []; 28 | 29 | /** 30 | * List of variants to exclude for further merges. 31 | * 32 | * @var array by variant name 33 | */ 34 | protected array $exclude = []; 35 | 36 | /** 37 | * @param array $variants 38 | */ 39 | public function __construct(array $variants) 40 | { 41 | $this->extractToArray($variants); 42 | } 43 | 44 | 45 | /** 46 | * @param array $variants 47 | */ 48 | public function mergeDefault(array $variants): void 49 | { 50 | $this->extractToArray($variants); 51 | } 52 | 53 | /** 54 | * @return array> by variant name 55 | */ 56 | public function variants(): array 57 | { 58 | return $this->variants; 59 | } 60 | 61 | /** 62 | * @return array by variant name 63 | */ 64 | public function extensions(): array 65 | { 66 | return $this->extensions; 67 | } 68 | 69 | /** 70 | * @return array by variant name 71 | */ 72 | public function urls(): array 73 | { 74 | return $this->urls; 75 | } 76 | 77 | 78 | /** 79 | * @param array $variants 80 | */ 81 | protected function extractToArray(array $variants): void 82 | { 83 | foreach ($variants as $variantName => $options) { 84 | // If a variant is configured specifically to be excluded, this should override any defaults. 85 | if ($options === false) { 86 | $this->markExcluded($variantName); 87 | continue; 88 | } 89 | 90 | if ($options instanceof Variant) { 91 | $variantName = $options->getName(); 92 | } 93 | 94 | // If the variant name is already set, don't overwrite anything. 95 | if (! $this->shouldMerge($variantName)) { 96 | continue; 97 | } 98 | 99 | if ($options instanceof Variant) { 100 | if ($options->getExtension()) { 101 | $this->extensions[ $variantName ] = $options->getExtension(); 102 | } 103 | 104 | if ($options->getUrl()) { 105 | $this->urls[ $variantName ] = $options->getUrl(); 106 | } 107 | 108 | $options = $options->getSteps(); 109 | } 110 | 111 | $this->variants[ $variantName ] = $this->normalizeVariantConfigEntry($options); 112 | } 113 | } 114 | 115 | /** 116 | * @param mixed $options 117 | * @return array 118 | */ 119 | protected function normalizeVariantConfigEntry(mixed $options): array 120 | { 121 | // Assume dimensions if a string (with dimensions). 122 | if (is_string($options)) { 123 | $options = [ 124 | 'resize' => [ 125 | 'dimensions' => $options, 126 | ], 127 | ]; 128 | } 129 | 130 | // Convert objects to arrays for fluent syntax support. 131 | if ($options instanceof Arrayable) { 132 | $options = [$options]; 133 | } 134 | 135 | if (array_key_exists('dimensions', $options)) { 136 | $options = [ 137 | 'resize' => $options, 138 | ]; 139 | } 140 | 141 | // If auto-orient is set, extract it to its own step. 142 | if ( 143 | ( 144 | Arr::get($options, 'resize.auto-orient') 145 | || Arr::get($options, 'resize.auto_orient') 146 | ) 147 | && ! Arr::has($options, 'auto-orient') 148 | ) { 149 | $options = array_merge(['auto-orient' => []], $options); 150 | 151 | Arr::forget($options, [ 152 | 'resize.auto-orient', 153 | 'resize.auto_orient', 154 | ]); 155 | } 156 | 157 | // Convert to array for fluent syntax support. 158 | $converted = []; 159 | 160 | foreach ($options as $key => $value) { 161 | if ($value instanceof Arrayable) { 162 | foreach ($value->toArray() as $nestedKey => $nestedValue) { 163 | $converted[ $nestedKey ] = $nestedValue; 164 | } 165 | continue; 166 | } 167 | 168 | $converted[ $key ] = $value; 169 | } 170 | 171 | return $converted; 172 | } 173 | 174 | /** 175 | * Returns whether variant configuration should be merged in for a given variant name. 176 | * 177 | * @param string $variantName 178 | * @return bool 179 | */ 180 | protected function shouldMerge(string $variantName): bool 181 | { 182 | return ! Arr::has($this->variants, $variantName) 183 | && ! Arr::get($this->exclude, $variantName); 184 | } 185 | 186 | /** 187 | * Mark variant name to be excluded from any further merges. 188 | * 189 | * @param string $variantName 190 | */ 191 | protected function markExcluded(string $variantName): void 192 | { 193 | $this->exclude[ $variantName ] = true; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Console/Commands/RefreshAttachmentCommand.php: -------------------------------------------------------------------------------- 1 | getAttachableModelInstanceForClass($this->argument('class')); 43 | 44 | $attachmentKeys = $this->getAttachmentsToProcess($model); 45 | 46 | if (empty($attachmentKeys)) { 47 | $this->warn('No attachments selected or available for this model, nothing to process.'); 48 | 49 | return static::INVALID; 50 | } 51 | 52 | $query = $this->getModelInstanceQuery($model); 53 | $count = $query->count(); 54 | 55 | $this->progressStart($count); 56 | 57 | foreach ($this->generateModelInstances($query, $count) as $instances) { 58 | /** @var \Illuminate\Support\Collection $instances */ 59 | foreach ($instances as $instance) { 60 | foreach ($instance->getAttachedFiles() as $attachmentKey => $attachment) { 61 | if (! in_array($attachmentKey, $attachmentKeys)) { 62 | continue; 63 | } 64 | 65 | $this->processAttachmentOnModelInstance($instance, $attachment); 66 | } 67 | 68 | $this->progressAdvance(); 69 | } 70 | } 71 | 72 | $this->progressFinish(); 73 | 74 | $this->info('Done.'); 75 | 76 | return static::SUCCESS; 77 | } 78 | 79 | /** 80 | * @param Model $model 81 | * @param AttachmentInterface $attachment 82 | * @throws ReprocessingFailureException 83 | */ 84 | protected function processAttachmentOnModelInstance(Model $model, AttachmentInterface $attachment): void 85 | { 86 | $specificVariants = $this->getVariantsToProcess(); 87 | $matchedVariants = array_intersect($specificVariants, $attachment->variants()); 88 | $processAllVariants = in_array('*', $specificVariants); 89 | 90 | 91 | if (! $processAllVariants && ! count($matchedVariants)) { 92 | throw new UnexpectedValueException( 93 | "Attachment '{$attachment->name()}' on " . get_class($model) 94 | . ' does not have any of the indicated variants (' . implode(', ', $specificVariants) . ')' 95 | ); 96 | } 97 | 98 | 99 | try { 100 | if ($processAllVariants) { 101 | $attachment->reprocess(); 102 | } else { 103 | $attachment->reprocess($specificVariants); 104 | } 105 | } catch (Throwable $e) { 106 | throw new ReprocessingFailureException( 107 | "Failed to reprocess attachment '{$attachment->name()}' of " 108 | . get_class($model) . " #{$model->getKey()}: {$e->getMessage()}", 109 | $e->getCode(), 110 | $e 111 | ); 112 | } 113 | } 114 | 115 | /** 116 | * @param string $class 117 | * @return AttachableInterface&Model 118 | */ 119 | protected function getAttachableModelInstanceForClass(string $class): AttachableInterface 120 | { 121 | if (! is_a($class, AttachableInterface::class, true)) { 122 | throw new RuntimeException("'{$class}' is not a valid attachable model class."); 123 | } 124 | 125 | return app($class); 126 | } 127 | 128 | /** 129 | * Returns the attachment names that should be processed for a given model. 130 | * 131 | * This takes into account the given attachments listed as a command option, 132 | * and filters out any attachments that don't exist. 133 | * 134 | * @param AttachableInterface $model 135 | * @return string[] 136 | */ 137 | protected function getAttachmentsToProcess(AttachableInterface $model): array 138 | { 139 | $attachments = $model->getAttachedFiles(); 140 | $attachmentNames = $this->option('attachments'); 141 | 142 | $availableAttachmentNames = array_map( 143 | fn (AttachmentInterface $attachment): string => $attachment->name(), 144 | $attachments 145 | ); 146 | 147 | if (empty($attachmentNames)) { 148 | return $availableAttachmentNames; 149 | } 150 | 151 | $attachmentNames = explode(',', str_replace(', ', ',', $attachmentNames)); 152 | 153 | $unavailableNames = array_diff($attachmentNames, $availableAttachmentNames); 154 | 155 | if (count($unavailableNames)) { 156 | throw new UnexpectedValueException( 157 | get_class($model) . ' does not have attachment(s): ' . implode(', ', $unavailableNames) 158 | ); 159 | } 160 | 161 | return array_intersect($availableAttachmentNames, $attachmentNames); 162 | } 163 | 164 | /** 165 | * @return string[] 166 | */ 167 | protected function getVariantsToProcess(): array 168 | { 169 | $variants = $this->option('variants'); 170 | 171 | if (! $variants) { 172 | return ['*']; 173 | } 174 | 175 | return explode(',', str_replace(', ', ',', $variants)); 176 | } 177 | 178 | /** 179 | * Returns base query for returning all model instances. 180 | * 181 | * @param Model $model 182 | * @return EloquentBuilder 183 | */ 184 | protected function getModelInstanceQuery(Model $model): EloquentBuilder 185 | { 186 | $query = $model->query(); 187 | 188 | $this->applyOrderingToModelInstanceQuery($query); 189 | $this->applyStartAndStopLimitsToQuery($query); 190 | 191 | return $query; 192 | } 193 | 194 | /** 195 | * Sets up and returns generator for model instances. 196 | * 197 | * This also starts the progress bar, given the total count of matched 198 | * 199 | * @param EloquentBuilder $query 200 | * @param int $totalCount 201 | * @return Generator> 202 | */ 203 | protected function generateModelInstances(EloquentBuilder $query, int $totalCount): Generator 204 | { 205 | $chunkSize = $this->getChunkSize(); 206 | 207 | $chunkCount = ceil($totalCount / $chunkSize); 208 | 209 | for ($x = 0; $x < $chunkCount; $x++) { 210 | $skip = $x * $chunkSize; 211 | 212 | yield $query 213 | ->skip($skip) 214 | ->take($chunkSize) 215 | ->get(); 216 | } 217 | } 218 | 219 | /** 220 | * @param EloquentBuilder $query 221 | */ 222 | protected function applyOrderingToModelInstanceQuery(EloquentBuilder $query): void 223 | { 224 | if ($this->option('dont-order')) { 225 | return; 226 | } 227 | 228 | $query->orderBy('id'); 229 | } 230 | 231 | /** 232 | * @param EloquentBuilder $query 233 | */ 234 | protected function applyStartAndStopLimitsToQuery(EloquentBuilder $query): void 235 | { 236 | $startAt = $this->option('start'); 237 | $stopAfter = $this->option('stop'); 238 | 239 | if (! $startAt && ! $stopAfter) { 240 | return; 241 | } 242 | 243 | $keyName = $query->getModel()->getKeyName(); 244 | 245 | if ($startAt) { 246 | $query->where($keyName, '>=', $startAt); 247 | } 248 | 249 | if ($stopAfter) { 250 | $query->where($keyName, '<=', $stopAfter); 251 | } 252 | } 253 | 254 | protected function getChunkSize(): int 255 | { 256 | return (int) config('paperclip.processing.chunk-size', static::DEFAULT_CHUNK_SIZE); 257 | } 258 | 259 | protected function progressStart(int $count): void 260 | { 261 | $this->output->progressStart($count); 262 | } 263 | 264 | protected function progressAdvance(): void 265 | { 266 | $this->output->progressAdvance(); 267 | } 268 | 269 | protected function progressFinish(): void 270 | { 271 | $this->output->progressFinish(); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/Contracts/AttachableInterface.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function getConfig(): array; 17 | 18 | /** 19 | * Returns the creation time of the file as originally assigned to this attachment's model. 20 | * Lives in the _created_at attribute of the model. 21 | * 22 | * This attribute may conditionally exist on the model, it is not one of the four required fields. 23 | * 24 | * @return string|null 25 | */ 26 | public function createdAt(): ?string; 27 | 28 | /** 29 | * Returns the last modified time of the file as originally assigned to this attachment's model. 30 | * Lives in the _updated_at attribute of the model. 31 | * 32 | * @return string|null 33 | */ 34 | public function updatedAt(): ?string; 35 | 36 | /** 37 | * Returns the content type of the file as originally assigned to this attachment's model. 38 | * Lives in the _content_type attribute of the model. 39 | * 40 | * @return string|null 41 | */ 42 | public function contentType(): ?string; 43 | 44 | /** 45 | * Returns the size of the file as originally assigned to this attachment's model. 46 | * Lives in the _file_size attribute of the model. 47 | * 48 | * @return int|null 49 | */ 50 | public function size(): ?int; 51 | 52 | /** 53 | * Returns the name of the file as originally assigned to this attachment's model. 54 | * Lives in the _file_name attribute of the model. 55 | * 56 | * @return string|null 57 | */ 58 | public function originalFilename(): ?string; 59 | 60 | /** 61 | * Returns the filename for a given variant. 62 | * 63 | * @param string|null $variant 64 | * @return string|false 65 | */ 66 | public function variantFilename(?string $variant): string|false; 67 | 68 | /** 69 | * Returns the extension for a given variant. 70 | * 71 | * @param string $variant 72 | * @return string|false 73 | */ 74 | public function variantExtension(string $variant): string|false; 75 | 76 | /** 77 | * Returns the mimeType for a given variant. 78 | * 79 | * @param string $variant 80 | * @return string|false 81 | */ 82 | public function variantContentType(string $variant): string|false; 83 | 84 | /** 85 | * Returns the JSON information stored on the model about variants as an associative array. 86 | * 87 | * @return array 88 | */ 89 | public function variantsAttribute(): array; 90 | 91 | /** 92 | * Returns the key for the underlying object instance. 93 | * 94 | * @return mixed 95 | */ 96 | public function getInstanceKey(): mixed; 97 | 98 | /** 99 | * Returns the class type of the attachment's underlying object instance. 100 | * 101 | * @return class-string 102 | */ 103 | public function getInstanceClass(): string; 104 | } 105 | -------------------------------------------------------------------------------- /src/Contracts/AttachmentFactoryInterface.php: -------------------------------------------------------------------------------- 1 | $config 11 | * @return AttachmentInterface 12 | */ 13 | public function create(AttachableInterface $instance, string $name, array $config = []): AttachmentInterface; 14 | } 15 | -------------------------------------------------------------------------------- /src/Contracts/AttachmentInterface.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | public function getNormalizedConfig(): array; 35 | 36 | /** 37 | * Sets the storage disk identifier. 38 | * 39 | * @param string|null $storage 40 | */ 41 | public function setStorage(?string $storage): void; 42 | 43 | public function getStorage(): ?string; 44 | 45 | public function setName(string $name): void; 46 | 47 | public function setInterpolator(InterpolatorInterface $interpolator): void; 48 | 49 | /** 50 | * Sets a file to be processed and stored. 51 | * 52 | * This is not done instantly. Rather, the file is queued for processing when the model is saved. 53 | * 54 | * @param StorableFileInterface $file 55 | */ 56 | public function setUploadedFile(StorableFileInterface $file): void; 57 | 58 | /** 59 | * Sets the attachment to be deleted. 60 | * 61 | * This does NOT override the preserve-files config option. 62 | */ 63 | public function setToBeDeleted(): void; 64 | 65 | /** 66 | * Reprocesses variants from the currently set original file. 67 | * 68 | * @param string[] $variants ['*'] for all 69 | */ 70 | public function reprocess(array $variants = ['*']): void; 71 | 72 | /** 73 | * Returns list of keys for defined variants. 74 | * 75 | * @param bool $withOriginal whether to include the original 'variant' key 76 | * @return string[] 77 | */ 78 | public function variants(bool $withOriginal = false): array; 79 | 80 | /** 81 | * Generates the url to an uploaded file (or a variant). 82 | * 83 | * @param string|null $variant 84 | * @return string|null 85 | */ 86 | public function url(?string $variant = null): ?string; 87 | 88 | /** 89 | * Returns the relative storage path for a variant. 90 | * 91 | * @param string|null $variant 92 | * @return string|null 93 | */ 94 | public function variantPath(?string $variant = null): ?string; 95 | 96 | /** 97 | * Returns whether this attachment actually has a file currently stored. 98 | * 99 | * @return bool 100 | */ 101 | public function exists(): bool; 102 | 103 | /** 104 | * Removes all uploaded files (from storage) for this attachment. 105 | * 106 | * This method does not clear out attachment attributes on the model instance. 107 | * 108 | * @param string[] $variants 109 | */ 110 | public function destroy(array $variants = []): void; 111 | 112 | /** 113 | * Processes the write queue. 114 | * 115 | * @param AttachableInterface&Model $instance 116 | */ 117 | public function afterSave(AttachableInterface $instance): void; 118 | 119 | /** 120 | * Queues up this attachments files for deletion. 121 | * 122 | * @param AttachableInterface&Model $instance 123 | */ 124 | public function beforeDelete(AttachableInterface $instance): void; 125 | 126 | /** 127 | * Processes the delete queue. 128 | * 129 | * @param AttachableInterface&Model $instance 130 | */ 131 | public function afterDelete(AttachableInterface $instance): void; 132 | } 133 | -------------------------------------------------------------------------------- /src/Contracts/Config/ConfigInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface ConfigInterface extends Arrayable 11 | { 12 | public function keepOldFiles(): bool; 13 | public function preserveFiles(): bool; 14 | public function storageDisk(): ?string; 15 | public function path(): string; 16 | public function variantPath(): ?string; 17 | public function sizeAttribute(): string|bool; 18 | public function contentTypeAttribute(): string|bool; 19 | public function updatedAtAttribute(): string|bool; 20 | public function createdAtAttribute(): string|bool; 21 | public function variantsAttribute(): string|bool; 22 | 23 | /** 24 | * Returns whether a configuration for a specific variant has been set. 25 | * 26 | * @param string $variant 27 | * @return bool 28 | */ 29 | public function hasVariantConfig(string $variant): bool; 30 | 31 | /** 32 | * Returns the configuration array for a specific variant. 33 | * 34 | * @param string $variant 35 | * @return array 36 | */ 37 | public function variantConfig(string $variant): array; 38 | 39 | /** 40 | * Returns an array with the variant configurations set. 41 | * 42 | * @return array> keyed by variant name 43 | */ 44 | public function variantConfigs(): array; 45 | 46 | /** 47 | * Returns the mimetype specifically configured for a given variant. 48 | * 49 | * @param string $variant 50 | * @return string|false 51 | */ 52 | public function variantMimeType(string $variant): string|false; 53 | 54 | /** 55 | * Returns an array with extensions configured per variant. 56 | * 57 | * @return array keyed by variant name 58 | */ 59 | public function variantExtensions(): array; 60 | 61 | /** 62 | * Returns the extension specifically configured for a given variant. 63 | * 64 | * Note that this does not determine the extension for the variant, 65 | * just what extension Paperclip should expect the created file to have. 66 | * 67 | * @param string $variant 68 | * @return string|false 69 | */ 70 | public function variantExtension(string $variant): string|false; 71 | 72 | /** 73 | * Returns the default URL to use when no attachment is stored. 74 | * 75 | * @return string|null 76 | */ 77 | public function defaultUrl(): ?string; 78 | 79 | /** 80 | * Returns the default URL for a variant to use when no attachment is stored. 81 | * 82 | * @param string $variant 83 | * @return string|null 84 | */ 85 | public function defaultVariantUrl(string $variant): ?string; 86 | 87 | /** 88 | * Returns whether a given attribute property should be saved. 89 | * 90 | * Expected values: 'created_at', 'content_type', ... 91 | * 92 | * @param string $attribute 93 | * @return bool 94 | */ 95 | public function attributeProperty(string $attribute): bool; 96 | 97 | /** 98 | * Returns the hook callable to run before storing an attachment. 99 | * 100 | * @return callable|callable-string|null 101 | */ 102 | public function beforeCallable(): callable|string|null; 103 | 104 | /** 105 | * Returns the hook callable to run after storing an attachment. 106 | * 107 | * @return callable|callable-string|null 108 | */ 109 | public function afterCallable(): callable|string|null; 110 | 111 | /** 112 | * Returns the unedited, non-normalized input config array. 113 | * 114 | * @return array 115 | */ 116 | public function getOriginalConfig(): array; 117 | } 118 | -------------------------------------------------------------------------------- /src/Contracts/FileHandlerFactoryInterface.php: -------------------------------------------------------------------------------- 1 | $information 17 | */ 18 | public function __construct( 19 | public readonly Throwable $exception, 20 | public readonly StorableFileInterface $source, 21 | public readonly string $variant, 22 | public readonly array $information, 23 | ) { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Events/TemporaryFileFailedToBeDeletedEvent.php: -------------------------------------------------------------------------------- 1 | getStorageDisk(); 25 | 26 | return new FileHandler( 27 | $this->makeStorage($storage ?? ''), 28 | $this->makeProcessor(), 29 | ); 30 | } 31 | 32 | /** 33 | * @param string $disk 34 | * @return LaravelStorage 35 | */ 36 | protected function makeStorage(string $disk): LaravelStorage 37 | { 38 | if (trim($disk) === '') { 39 | throw new RuntimeException( 40 | "Paperclip storage disk invalid or null, check your paperclip and filesystems configuration" 41 | ); 42 | } 43 | 44 | if (! $this->isStorageDiskAvailable($disk)) { 45 | throw new RuntimeException( 46 | "Paperclip storage disk '{$disk}' is not configured! " 47 | . 'Add an entry for it under the filesystems.disks configuration key.' 48 | ); 49 | } 50 | 51 | $isLocal = $this->isDiskLocal($disk); 52 | $baseUrl = $this->getBaseUrlForDisk($disk); 53 | 54 | return new LaravelStorage($this->getLaravelStorageInstance($disk), $isLocal, $baseUrl); 55 | } 56 | 57 | protected function makeProcessor(): VariantProcessorInterface 58 | { 59 | return app(VariantProcessorInterface::class); 60 | } 61 | 62 | /** 63 | * Returns whether a given disk alias is for default local storage. 64 | * 65 | * @param string $disk 66 | * @return bool 67 | */ 68 | protected function isDiskLocal(string $disk): bool 69 | { 70 | return config("filesystems.disks.{$disk}.driver") === static::LOCAL_DRIVER; 71 | } 72 | 73 | /** 74 | * Returns the storage disk to use. If no paperclip storage is defined, the default storage is used. 75 | * 76 | * @return string|null 77 | */ 78 | protected function getStorageDisk(): ?string 79 | { 80 | return config('paperclip.storage.disk') 81 | ?: config('filesystems.default'); 82 | } 83 | 84 | /** 85 | * Checks whether the given storage driver is available. 86 | * 87 | * @param string $driver 88 | * @return bool 89 | */ 90 | protected function isStorageDiskAvailable(string $driver): bool 91 | { 92 | return array_key_exists($driver, config('filesystems.disks', [])); 93 | } 94 | 95 | /** 96 | * Returns the (external) base URL to use for a given storage disk. 97 | * 98 | * @param string $disk 99 | * @return string 100 | */ 101 | protected function getBaseUrlForDisk(string $disk): string 102 | { 103 | $url = config("paperclip.storage.base-urls.{$disk}") ?: config("filesystems.disks.{$disk}.url"); 104 | 105 | if (is_string($url)) { 106 | return $url; 107 | } 108 | 109 | // Attempt to get URL from cloud storage directly. 110 | try { 111 | $storage = $this->getLaravelStorageInstance($disk); 112 | 113 | if ($storage instanceof CloudFilesystemContract) { 114 | $url = $storage->url('.'); 115 | } 116 | } catch (Throwable) { 117 | throw new RuntimeException("Could not determine base URL through Storage::url() for '{$disk}'"); 118 | } 119 | 120 | if (is_string($url)) { 121 | return $url; 122 | } 123 | 124 | throw new RuntimeException("Could not determine base URL for storage disk '{$disk}'"); 125 | } 126 | 127 | protected function getLaravelStorageInstance(string $disk): FilesystemContract|CloudFilesystemContract 128 | { 129 | return Storage::disk($disk); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Model/PaperclipTrait.php: -------------------------------------------------------------------------------- 1 | attachedFiles; 43 | } 44 | 45 | /** 46 | * Add a new file attachment type to the list of available attachments. 47 | * 48 | * @param string $name 49 | * @param array $options 50 | */ 51 | public function hasAttachedFile(string $name, array $options = []): void 52 | { 53 | $factory = $this->makeAttachmentFactory(); 54 | 55 | $attachment = $factory->create($this, $name, $options); 56 | 57 | $this->attachedFiles[ $name ] = $attachment; 58 | } 59 | 60 | public static function bootPaperclipTrait(): void 61 | { 62 | static::saved(function ($model): void { 63 | /** @var static&AttachableInterface $model */ 64 | if ($model->attachedUpdated) { 65 | // Unmark attachment being updated, so the processing isn't fired twice 66 | // when the attached file performs a model update for the `variants` column. 67 | $model->attachedUpdated = false; 68 | 69 | foreach ($model->getAttachedFiles() as $attachedFile) { 70 | $attachedFile->afterSave($model); 71 | } 72 | 73 | $model->mergeFileAttributes(); 74 | } 75 | }); 76 | 77 | static::saving(function ($model): void { 78 | /** @var static $model */ 79 | $model->removeFileAttributes(); 80 | }); 81 | 82 | static::updating(function ($model): void { 83 | /** @var static $model */ 84 | $model->removeFileAttributes(); 85 | }); 86 | 87 | static::retrieved(function ($model): void { 88 | /** @var static $model */ 89 | $model->mergeFileAttributes(); 90 | }); 91 | 92 | static::deleting(function ($model): void { 93 | /** @var static&AttachableInterface $model */ 94 | foreach ($model->getAttachedFiles() as $attachedFile) { 95 | $attachedFile->beforeDelete($model); 96 | } 97 | }); 98 | 99 | static::deleted(function ($model): void { 100 | /** @var Model&AttachableInterface $model */ 101 | foreach ($model->getAttachedFiles() as $attachedFile) { 102 | $attachedFile->afterDelete($model); 103 | } 104 | }); 105 | } 106 | 107 | /** 108 | * Handle the dynamic retrieval of attachment objects. 109 | * 110 | * @param string $key 111 | * @return mixed 112 | */ 113 | public function getAttribute(mixed $key): mixed 114 | { 115 | if (array_key_exists($key, $this->attachedFiles)) { 116 | return $this->attachedFiles[ $key ]; 117 | } 118 | 119 | return parent::getAttribute($key); 120 | } 121 | 122 | /** 123 | * Handles the setting of attachment objects. 124 | * 125 | * @param string $key 126 | * @param mixed $value 127 | * @return mixed|$this 128 | */ 129 | public function setAttribute(mixed $key, mixed $value): mixed 130 | { 131 | if (array_key_exists($key, $this->attachedFiles)) { 132 | if ($value) { 133 | $attachedFile = $this->attachedFiles[ $key ]; 134 | 135 | if ($value === $this->getDeleteAttachmentString()) { 136 | $attachedFile->setToBeDeleted(); 137 | return $this; 138 | } 139 | 140 | $factory = $this->makeStorableFileFactory(); 141 | 142 | $attachedFile->setUploadedFile( 143 | $factory->makeFromAny($value) 144 | ); 145 | } 146 | 147 | $this->attachedUpdated = true; 148 | 149 | return $this; 150 | } 151 | 152 | return parent::setAttribute($key, $value); 153 | } 154 | 155 | /** 156 | * Overridden to prevent attempts to persist attachment attributes directly. 157 | * 158 | * Reason this is required: Laravel 5.5 changed the getDirty() behavior. 159 | * 160 | * {@inheritDoc} 161 | */ 162 | public function originalIsEquivalent($key) 163 | { 164 | if (array_key_exists($key, $this->attachedFiles)) { 165 | return true; 166 | } 167 | 168 | return parent::originalIsEquivalent($key); 169 | } 170 | 171 | /** 172 | * Return the image paths for a given attachment. 173 | * 174 | * @param string $attachmentName 175 | * @return string[] 176 | */ 177 | public function pathsForAttachment(string $attachmentName): array 178 | { 179 | $paths = []; 180 | 181 | if (isset($this->attachedFiles[ $attachmentName ])) { 182 | $attachment = $this->attachedFiles[ $attachmentName ]; 183 | 184 | foreach ($attachment->variants(true) as $variant) { 185 | $paths[ $variant ] = $attachment->variantPath($variant); 186 | } 187 | } 188 | 189 | return $paths; 190 | } 191 | 192 | /** 193 | * Return the image urls for a given attachment. 194 | * 195 | * @param string $attachmentName 196 | * @return string[] 197 | */ 198 | public function urlsForAttachment(string $attachmentName): array 199 | { 200 | $urls = []; 201 | 202 | if (isset($this->attachedFiles[ $attachmentName ])) { 203 | $attachment = $this->attachedFiles[ $attachmentName ]; 204 | 205 | foreach ($attachment->variants(true) as $variant) { 206 | $urls[ $variant ] = $attachment->url($variant); 207 | } 208 | } 209 | 210 | return $urls; 211 | } 212 | 213 | /** 214 | * Marks that at least one attachment on the model has been updated and should be processed. 215 | */ 216 | public function markAttachmentUpdated(): void 217 | { 218 | $this->attachedUpdated = true; 219 | } 220 | 221 | /** 222 | * Add the attached files to the model's attributes. 223 | */ 224 | public function mergeFileAttributes(): void 225 | { 226 | $this->attributes = $this->attributes + $this->getAttachedFiles(); 227 | } 228 | 229 | /** 230 | * Remove any attached file attributes, so they aren't sent to the database. 231 | */ 232 | public function removeFileAttributes(): void 233 | { 234 | foreach (array_keys($this->getAttachedFiles()) as $key) { 235 | unset($this->attributes[$key]); 236 | } 237 | } 238 | 239 | /** 240 | * Returns the string with which an attachment can be deleted. 241 | * 242 | * @return string 243 | */ 244 | protected function getDeleteAttachmentString(): string 245 | { 246 | return config('paperclip.delete-hash', Attachment::NULL_ATTACHMENT); 247 | } 248 | 249 | protected function makeAttachmentFactory(): AttachmentFactoryInterface 250 | { 251 | return app(AttachmentFactoryInterface::class); 252 | } 253 | 254 | protected function makeStorableFileFactory(): StorableFileFactoryInterface 255 | { 256 | return app(StorableFileFactoryInterface::class); 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/Path/InterpolatingTarget.php: -------------------------------------------------------------------------------- 1 | interpolator = $interpolator; 34 | $this->attachment = $attachment; 35 | 36 | parent::__construct($path, $variantPath); 37 | } 38 | 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function original(): string 44 | { 45 | return $this->interpolator->interpolate($this->originalPath, $this->attachment); 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function variant($variant): string 52 | { 53 | return $this->interpolator->interpolate($this->getVariantPath(), $this->attachment, $variant); 54 | } 55 | 56 | protected function getVariantPath(): string 57 | { 58 | return $this->variantPath ?: $this->originalPath; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Path/Interpolator.php: -------------------------------------------------------------------------------- 1 | interpolations() as $key => $value) { 26 | if (str_contains($string, $key)) { 27 | $string = preg_replace("/$key\b/", $this->$value($attachment, $variant), $string); 28 | } 29 | } 30 | 31 | return $string; 32 | } 33 | 34 | /** 35 | * Returns a sorted list of all interpolations. 36 | * 37 | * @return array 38 | */ 39 | protected function interpolations(): array 40 | { 41 | return [ 42 | ':app_root' => 'appRoot', 43 | ':attribute' => 'getName', 44 | ':attachment' => 'attachment', 45 | ':basename' => 'basename', 46 | ':class_name' => 'getClassName', 47 | ':class' => 'getClass', 48 | ':extension' => 'extension', 49 | ':filename' => 'filename', 50 | ':hash' => 'hash', 51 | ':id' => 'id', 52 | ':id_partition' => 'idPartition', 53 | ':namespace' => 'getNamespace', 54 | ':name' => 'getName', 55 | ':secure_hash' => 'secureHash', 56 | ':style' => 'style', 57 | ':variant' => 'style', 58 | ]; 59 | } 60 | 61 | /** 62 | * Returns the file name. 63 | * 64 | * @param AttachmentDataInterface $attachment 65 | * @param string $variant 66 | * @return string 67 | */ 68 | protected function filename(AttachmentDataInterface $attachment, string $variant = ''): string 69 | { 70 | if ($variant) { 71 | return $attachment->variantFilename($variant); 72 | } 73 | 74 | return $attachment->originalFilename(); 75 | } 76 | 77 | /** 78 | * Returns the application root of the project. 79 | * 80 | * @param AttachmentDataInterface $attachment 81 | * @param string $variant 82 | * @return string 83 | */ 84 | protected function appRoot(AttachmentDataInterface $attachment, string $variant = ''): string 85 | { 86 | return app_path(); 87 | } 88 | 89 | /** 90 | * Returns the current class name, taking into account namespaces, e.g 91 | * 'Swingline\Stapler' will become Swingline/Stapler. 92 | * 93 | * @param AttachmentDataInterface $attachment 94 | * @param string $variant 95 | * @return string 96 | */ 97 | protected function getClass(AttachmentDataInterface $attachment, string $variant = ''): string 98 | { 99 | return $this->handleBackslashes($attachment->getInstanceClass()); 100 | } 101 | 102 | /** 103 | * Returns the current class name, not taking into account namespaces, e.g. 104 | * 105 | * @param AttachmentDataInterface $attachment 106 | * @param string $variant 107 | * @return string 108 | */ 109 | protected function getClassName(AttachmentDataInterface $attachment, string $variant = ''): string 110 | { 111 | $classComponents = explode('\\', $attachment->getInstanceClass()); 112 | 113 | return end($classComponents); 114 | } 115 | 116 | /** 117 | * Returns the current class name, exclusively taking into account namespaces, e.g 118 | * 'Swingline\Stapler' will become Swingline. 119 | * 120 | * @param AttachmentDataInterface $attachment 121 | * @param string $variant 122 | * @return string 123 | */ 124 | protected function getNamespace(AttachmentDataInterface $attachment, string $variant = ''): string 125 | { 126 | $classComponents = explode('\\', $attachment->getInstanceClass()); 127 | 128 | return implode('/', array_slice($classComponents, 0, count($classComponents) - 1)); 129 | } 130 | 131 | /** 132 | * Returns the basename portion of the attached file, e.g 'file' for file.jpg. 133 | * 134 | * @param AttachmentDataInterface $attachment 135 | * @param string $variant 136 | * @return string 137 | */ 138 | protected function basename(AttachmentDataInterface $attachment, string $variant = ''): string 139 | { 140 | return pathinfo($this->filename($attachment, $variant), PATHINFO_FILENAME); 141 | } 142 | 143 | /** 144 | * Returns the extension of the attached file, e.g 'jpg' for file.jpg. 145 | * 146 | * @param AttachmentDataInterface $attachment 147 | * @param string $variant 148 | * @return string 149 | */ 150 | protected function extension(AttachmentDataInterface $attachment, string $variant = ''): string 151 | { 152 | return pathinfo($this->filename($attachment, $variant), PATHINFO_EXTENSION); 153 | } 154 | 155 | /** 156 | * Returns the id of the current object instance. 157 | * 158 | * @param AttachmentDataInterface $attachment 159 | * @param string $variant 160 | * @return string 161 | */ 162 | protected function id(AttachmentDataInterface $attachment, string $variant = ''): string 163 | { 164 | return (string) $this->ensurePrintable($attachment->getInstanceKey()); 165 | } 166 | 167 | /** 168 | * Return a secure Bcrypt hash of the attachment's corresponding instance id. 169 | * 170 | * @param AttachmentDataInterface $attachment 171 | * @param string $variant 172 | * @return string 173 | */ 174 | protected function secureHash(AttachmentDataInterface $attachment, string $variant = ''): string 175 | { 176 | return hash( 177 | 'sha256', 178 | $this->id($attachment, $variant) . $attachment->size() . $this->filename($attachment, $variant) 179 | ); 180 | } 181 | 182 | /** 183 | * Return a Bcrypt hash of the attachment's corresponding instance id. 184 | * 185 | * @param AttachmentDataInterface $attachment 186 | * @param string $variant 187 | * @return string 188 | */ 189 | protected function hash(AttachmentDataInterface $attachment, string $variant = ''): string 190 | { 191 | return hash('sha256', $this->id($attachment, $variant)); 192 | } 193 | 194 | /** 195 | * Generates the id partition of a record, e.g /000/001/234 for an id of 1234. 196 | * 197 | * @param AttachmentDataInterface $attachment 198 | * @param string $variant 199 | * @return string 200 | */ 201 | protected function idPartition(AttachmentDataInterface $attachment, string $variant = ''): string 202 | { 203 | $id = $this->ensurePrintable($attachment->getInstanceKey()); 204 | 205 | if (is_numeric($id)) { 206 | return implode('/', str_split(sprintf('%09d', $id), 3)); 207 | } 208 | 209 | if (is_string($id)) { 210 | return implode('/', array_slice(str_split($id, 3), 0, 3)); 211 | } 212 | 213 | // @codeCoverageIgnoreStart 214 | return ''; 215 | // @codeCoverageIgnoreEnd 216 | } 217 | 218 | /** 219 | * Returns the pluralized form of the attachment name. e.g. 220 | * 'avatars' for an attachment of :avatar. 221 | * 222 | * @param AttachmentDataInterface $attachment 223 | * @param string $variant 224 | * @return string 225 | */ 226 | protected function attachment(AttachmentDataInterface $attachment, string $variant = ''): string 227 | { 228 | return Str::plural($attachment->name()); 229 | } 230 | 231 | /** 232 | * Returns the style, or the default style if an empty style is supplied. 233 | * 234 | * @param AttachmentDataInterface $attachment 235 | * @param string $variant 236 | * @return string 237 | */ 238 | protected function style(AttachmentDataInterface $attachment, string $variant = ''): string 239 | { 240 | if ($variant) { 241 | return $variant; 242 | } 243 | 244 | return Arr::get($attachment->getConfig(), 'default-variant', FileHandler::ORIGINAL); 245 | } 246 | 247 | /** 248 | * Returns the attachment attribute name. 249 | * 250 | * @param AttachmentDataInterface $attachment 251 | * @param string $variant 252 | * @return string 253 | */ 254 | protected function getName(AttachmentDataInterface $attachment, string $variant = ''): string 255 | { 256 | return $attachment->name(); 257 | } 258 | 259 | 260 | /** 261 | * Utility function to turn a back-slashed string into a string 262 | * suitable for use in a file path, e.g '\foo\bar' becomes 'foo/bar'. 263 | * 264 | * @param string $string 265 | * @return string 266 | */ 267 | protected function handleBackslashes(string $string): string 268 | { 269 | return str_replace('\\', '/', ltrim($string, '\\')); 270 | } 271 | 272 | /** 273 | * Utility method to ensure the input data only contains printable characters. 274 | * This is especially important when handling non-printable ID's such as binary UUID's. 275 | * 276 | * @param mixed $input 277 | * @return mixed 278 | */ 279 | protected function ensurePrintable(mixed $input): mixed 280 | { 281 | if ($input === null) { 282 | return $input; 283 | } 284 | 285 | if (is_numeric($input) || ctype_print($input)) { 286 | return $input; 287 | } 288 | 289 | // Hash the input data with SHA-256 to represent as printable characters, with minimum chances 290 | // of the uniqueness being lost. 291 | return hash('sha256', $input); 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/Providers/PaperclipServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootConfig(); 39 | } 40 | 41 | public function register(): void 42 | { 43 | $this->registerConfig(); 44 | $this->registerCommands(); 45 | $this->registerInterfaceBindings(); 46 | } 47 | 48 | protected function bootConfig(): void 49 | { 50 | $this->publishes([ 51 | realpath(dirname(__DIR__) . '/../config/paperclip.php') => config_path('paperclip.php'), 52 | ]); 53 | } 54 | 55 | protected function registerConfig(): void 56 | { 57 | $this->mergeConfigFrom( 58 | realpath(dirname(__DIR__) . '/../config/paperclip.php'), 59 | 'paperclip' 60 | ); 61 | } 62 | 63 | protected function registerInterfaceBindings(): void 64 | { 65 | $this->registerFileHandlerInterfaceBindings(); 66 | $this->registerVariantStrategyFactory(); 67 | 68 | $this->app->singleton(FileHandlerFactoryInterface::class, FileHandlerFactory::class); 69 | $this->app->singleton(AttachmentFactoryInterface::class, AttachmentFactory::class); 70 | $this->app->singleton(ImagineInterface::class, $this->getImagineImplementationClass()); 71 | } 72 | 73 | protected function registerFileHandlerInterfaceBindings(): void 74 | { 75 | $this->app->singleton(VariantProcessorInterface::class, VariantProcessor::class); 76 | $this->app->singleton(StorableFileFactoryInterface::class, StorableFileFactory::class); 77 | $this->app->singleton(MimeTypeHelperInterface::class, MimeTypeHelper::class); 78 | $this->app->singleton(ContentInterpreterInterface::class, UploadedContentInterpreter::class); 79 | $this->app->singleton(UrlDownloaderInterface::class, UrlDownloader::class); 80 | $this->app->singleton(UriValidatorInterface::class, UriValidator::class); 81 | $this->app->singleton(InterpolatorInterface::class, Interpolator::class); 82 | } 83 | 84 | protected function registerVariantStrategyFactory(): void 85 | { 86 | $this->app->singleton( 87 | VariantStrategyFactoryInterface::class, 88 | fn (Application $app) => (new VariantStrategyFactory(new LaravelContainerDecorator($app))) 89 | ->setConfig([ 90 | 'aliases' => config('paperclip.variants.aliases', []), 91 | ]) 92 | ); 93 | } 94 | 95 | protected function registerCommands(): void 96 | { 97 | $this->app->singleton('paperclip.commands.refresh', RefreshAttachmentCommand::class); 98 | 99 | $this->commands([ 100 | 'paperclip.commands.refresh', 101 | ]); 102 | } 103 | 104 | /** 105 | * @return class-string 106 | */ 107 | protected function getImagineImplementationClass(): string 108 | { 109 | return config('paperclip.imagine', Imagine::class); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/Attachment/AttachmentDataTest.php: -------------------------------------------------------------------------------- 1 | getAttachmentData(); 19 | 20 | static::assertEquals('attachment', $data->name()); 21 | } 22 | 23 | /** 24 | * @test 25 | */ 26 | public function it_returns_the_configuration(): void 27 | { 28 | $data = $this->getAttachmentData(); 29 | 30 | static::assertEquals(['url' => '/test/:filename'], $data->getConfig()); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function it_returns_basic_attributes(): void 37 | { 38 | $data = $this->getAttachmentData(); 39 | 40 | static::assertEquals('some.jpg', $data->originalFilename()); 41 | static::assertEquals(511, $data->size()); 42 | static::assertEquals('image/jpg', $data->contentType()); 43 | static::assertEquals('2018-01-01 01:01:02', $data->updatedAt()); 44 | static::assertEquals('2018-01-01 01:01:01', $data->createdAt()); 45 | static::assertEquals(['a' => ['ext' => 'txt']], $data->variantsAttribute()); 46 | } 47 | 48 | /** 49 | * @test 50 | */ 51 | public function it_returns_empty_values_if_basic_attributes_are_not_set(): void 52 | { 53 | $data = $this->getEmptyAttachmentData(); 54 | 55 | static::assertNull($data->originalFilename()); 56 | static::assertNull($data->size()); 57 | static::assertNull($data->contentType()); 58 | static::assertNull($data->updatedAt()); 59 | static::assertNull($data->createdAt()); 60 | static::assertEquals([], $data->variantsAttribute()); 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function it_returns_variant_attributes(): void 67 | { 68 | $data = $this->getAttachmentData(); 69 | 70 | static::assertEquals('alternative', $data->variantFilename('a')); 71 | static::assertEquals('txt', $data->variantExtension('a')); 72 | static::assertEquals('text/plain', $data->variantContentType('a')); 73 | } 74 | 75 | /** 76 | * @test 77 | */ 78 | public function it_returns_false_values_if_variant_attributes_are_unset(): void 79 | { 80 | $data = $this->getAttachmentData(); 81 | 82 | static::assertFalse($data->variantFilename('b')); 83 | static::assertFalse($data->variantExtension('b')); 84 | static::assertFalse($data->variantContentType('b')); 85 | } 86 | 87 | /** 88 | * @test 89 | */ 90 | public function it_returns_instance_attributes(): void 91 | { 92 | $data = $this->getAttachmentData(); 93 | 94 | static::assertEquals(13, $data->getInstanceKey()); 95 | static::assertEquals(TestModel::class, $data->getInstanceClass()); 96 | } 97 | 98 | protected function getAttachmentData(): AttachmentData 99 | { 100 | return new AttachmentData( 101 | 'attachment', 102 | ['url' => '/test/:filename'], 103 | [ 104 | 'file_name' => 'some.jpg', 105 | 'file_size' => 511, 106 | 'content_type' => 'image/jpg', 107 | 'updated_at' => '2018-01-01 01:01:02', 108 | 'created_at' => '2018-01-01 01:01:01', 109 | 'variants' => [ 110 | 'a' => [ 111 | 'ext' => 'txt', 112 | ], 113 | ], 114 | ], 115 | [ 116 | 'a' => [ 117 | 'file_name' => 'alternative', 118 | 'content_type' => 'text/plain', 119 | 'extension' => 'txt', 120 | ], 121 | ], 122 | 13, 123 | TestModel::class, 124 | ); 125 | } 126 | 127 | protected function getEmptyAttachmentData(): AttachmentData 128 | { 129 | return new AttachmentData( 130 | 'attachment', 131 | [], 132 | [], 133 | [], 134 | null, 135 | TestModel::class, 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/Attachment/AttachmentSerializationTest.php: -------------------------------------------------------------------------------- 1 | getTestModel(); 20 | 21 | $interpolator = new Interpolator(); 22 | 23 | $attachment = new Attachment; 24 | $attachment->setName('testing'); 25 | $attachment->setInstance($model); 26 | $attachment->setInterpolator($interpolator); 27 | $attachment->setStorage('paperclip'); 28 | 29 | $serialized = serialize($attachment); 30 | 31 | // Check if the file handler is actually restored properly after serialization. 32 | static::assertInstanceOf(FileHandlerInterface::class, $attachment->getHandler()); 33 | static::assertIsString($serialized); 34 | 35 | /** @var Attachment $unserialized */ 36 | $unserialized = unserialize($serialized); 37 | 38 | static::assertInstanceOf(Attachment::class, $unserialized); 39 | static::assertInstanceOf(FileHandlerInterface::class, $unserialized->getHandler()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Attachment/AttachmentTest.php: -------------------------------------------------------------------------------- 1 | getTestModel(); 26 | 27 | $attachment = new Attachment; 28 | $attachment->setInstance($model); 29 | 30 | static::assertSame($model, $attachment->getInstance()); 31 | static::assertEquals(get_class($model), $attachment->getInstanceClass()); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function it_takes_and_returns_a_name(): void 38 | { 39 | $attachment = new Attachment; 40 | $attachment->setName('test'); 41 | 42 | static::assertEquals('test', $attachment->name()); 43 | } 44 | 45 | /** 46 | * @test 47 | * @doesNotPerformAssertions 48 | */ 49 | public function it_takes_an_interpolator(): void 50 | { 51 | /** @var InterpolatorInterface $interpolator */ 52 | $interpolator = Mockery::mock(InterpolatorInterface::class); 53 | 54 | $attachment = new Attachment; 55 | $attachment->setInterpolator($interpolator); 56 | } 57 | 58 | /** 59 | * @test 60 | */ 61 | public function it_takes_and_returns_a_configuration(): void 62 | { 63 | $attachment = new Attachment; 64 | $attachment->setConfig(new PaperclipConfig(['test' => true])); 65 | 66 | static::assertEquals(['test' => true], $attachment->getConfig()); 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | public function it_takes_and_returns_a_storage_identifier_and_handler(): void 73 | { 74 | $handler = $this->getMockHandler(); 75 | $this->app->instance(FileHandlerFactoryInterface::class, $this->getMockHandlerFactory($handler)); 76 | 77 | $attachment = new Attachment; 78 | $attachment->setStorage('test'); 79 | 80 | static::assertSame('test', $attachment->getStorage()); 81 | static::assertSame($handler, $attachment->getHandler()); 82 | } 83 | 84 | /** 85 | * @test 86 | */ 87 | public function it_returns_variant_keys_as_configured(): void 88 | { 89 | $attachment = new Attachment; 90 | $attachment->setConfig(new PaperclipConfig([ 91 | 'variants' => [ 92 | 'some' => [], 93 | 'variant' => [], 94 | 'keys' => [], 95 | ], 96 | ])); 97 | 98 | static::assertEquals(['some', 'variant', 'keys'], $attachment->variants()); 99 | } 100 | 101 | /** 102 | * @test 103 | */ 104 | public function it_returns_the_url_for_a_variant(): void 105 | { 106 | $model = $this->getTestModel(); 107 | $handler = $this->getMockHandler(); 108 | $interpolator = $this->getMockInterpolator(); 109 | 110 | $this->app->instance(FileHandlerFactoryInterface::class, $this->getMockHandlerFactory($handler)); 111 | 112 | $attachment = new Attachment; 113 | $attachment->setInstance($model); 114 | $attachment->setStorage('paperclip'); 115 | $attachment->setInterpolator($interpolator); 116 | $attachment->setName('image'); 117 | 118 | $model->setAttribute('image_file_name', 'test.png'); 119 | 120 | $handler->shouldReceive('variantUrlsForTarget')->once() 121 | ->with(Matchers::any(TargetInterface::class), ['variantkey']) 122 | ->andReturn(['variantkey' => 'http://fake.url/file/variantkey']); 123 | 124 | static::assertEquals('http://fake.url/file/variantkey', $attachment->url('variantkey')); 125 | } 126 | 127 | /** 128 | * @test 129 | */ 130 | public function it_returns_the_original_path(): void 131 | { 132 | $interpolator = $this->getMockInterpolator(); 133 | 134 | $attachment = new Attachment; 135 | $attachment->setInterpolator($interpolator); 136 | 137 | $interpolator->shouldReceive('interpolate') 138 | ->once() 139 | ->with( 140 | ':class/:id_partition/:attribute/:variant/:filename', 141 | $attachment 142 | ) 143 | ->andReturn('file/test.png'); 144 | 145 | static::assertEquals('file/test.png', $attachment->path()); 146 | } 147 | 148 | /** 149 | * @test 150 | */ 151 | public function it_returns_the_variant_path_for_a_variant(): void 152 | { 153 | $model = $this->getTestModel(); 154 | $handler = $this->getMockHandler(); 155 | $interpolator = $this->getMockInterpolator(); 156 | 157 | $this->app->instance(FileHandlerFactoryInterface::class, $this->getMockHandlerFactory($handler)); 158 | 159 | $attachment = new Attachment; 160 | $attachment->setInstance($model); 161 | $attachment->setStorage('paperclip'); 162 | $attachment->setInterpolator($interpolator); 163 | $attachment->setName('image'); 164 | 165 | $model->setAttribute('image_file_name', 'test.png'); 166 | 167 | $interpolator->shouldReceive('interpolate')->once() 168 | ->with(':class/:id_partition/:attribute/:variant/:filename', $attachment, 'variantkey') 169 | ->andReturn('file/variantkey/test.png'); 170 | 171 | static::assertEquals('file/variantkey/test.png', $attachment->variantPath('variantkey')); 172 | } 173 | 174 | /** 175 | * @test 176 | */ 177 | public function it_returns_the_filename_for_a_variant(): void 178 | { 179 | $model = $this->getTestModel(); 180 | 181 | $attachment = new Attachment; 182 | $attachment->setInstance($model); 183 | $attachment->setName('image'); 184 | 185 | $model->setAttribute('image_file_name', 'test.png'); 186 | 187 | static::assertEquals('test.png', $attachment->variantFilename('variantkey')); 188 | } 189 | 190 | /** 191 | * @test 192 | */ 193 | public function it_returns_the_filename_for_a_variant_if_it_has_a_different_extension(): void 194 | { 195 | $model = $this->getTestModel(); 196 | 197 | $attachment = new Attachment; 198 | $attachment->setInstance($model); 199 | $attachment->setName('image'); 200 | $attachment->setConfig(new PaperclipConfig([ 201 | 'extensions' => [ 202 | 'variantkey' => 'txt', 203 | ], 204 | ])); 205 | 206 | $model->setAttribute('image_file_name', 'test.png'); 207 | 208 | static::assertEquals('test.txt', $attachment->variantFilename('variantkey')); 209 | } 210 | 211 | /** 212 | * @test 213 | */ 214 | public function it_returns_the_extension_for_a_variant_if_it_is_configured_with_a_different_extension(): void 215 | { 216 | $model = $this->getTestModel(); 217 | 218 | $attachment = new Attachment; 219 | $attachment->setInstance($model); 220 | $attachment->setConfig(new PaperclipConfig([ 221 | 'extensions' => [ 222 | 'variantkey' => 'txt', 223 | ], 224 | ])); 225 | $attachment->setName('image'); 226 | 227 | $model->setAttribute('image_file_name', 'test.png'); 228 | 229 | static::assertEquals('txt', $attachment->variantExtension('variantkey')); 230 | } 231 | 232 | /** 233 | * @test 234 | */ 235 | public function it_returns_the_extension_for_a_variant_if_it_is_stored_in_variants_json_data_with_a_different_extension(): void 236 | { 237 | $model = $this->getTestModel(); 238 | 239 | $attachment = new Attachment; 240 | $attachment->setInstance($model); 241 | $attachment->setConfig(new PaperclipConfig([ 242 | 'attributes' => [ 243 | 'variants' => true, 244 | ], 245 | ])); 246 | $attachment->setName('image'); 247 | 248 | $model->setAttribute('image_file_name', 'test.png'); 249 | $model->setAttribute('image_variants', json_encode(['variantkey' => ['ext' => 'txt']])); 250 | 251 | static::assertEquals('txt', $attachment->variantExtension('variantkey')); 252 | } 253 | 254 | /** 255 | * @test 256 | */ 257 | public function it_returns_the_content_type_for_a_variant_if_it_is_the_same_as_the_original(): void 258 | { 259 | $model = $this->getTestModel(); 260 | 261 | $attachment = new Attachment; 262 | $attachment->setInstance($model); 263 | $attachment->setName('image'); 264 | 265 | $model->setAttribute('image_file_name', 'test.png'); 266 | $model->setAttribute('image_content_type', 'image/png'); 267 | 268 | static::assertEquals('image/png', $attachment->variantContentType('variantkey')); 269 | } 270 | 271 | /** 272 | * @test 273 | */ 274 | public function it_returns_the_content_type_for_a_variant_if_it_is_configured_with_a_different_type(): void 275 | { 276 | $model = $this->getTestModel(); 277 | 278 | $attachment = new Attachment; 279 | $attachment->setInstance($model); 280 | $attachment->setConfig(new PaperclipConfig([ 281 | 'types' => [ 282 | 'variantkey' => 'text/plain', 283 | ], 284 | ])); 285 | $attachment->setName('image'); 286 | 287 | $model->setAttribute('image_file_name', 'test.png'); 288 | $model->setAttribute('image_content_type', 'image/png'); 289 | 290 | static::assertEquals('text/plain', $attachment->variantContentType('variantkey')); 291 | } 292 | 293 | /** 294 | * @test 295 | */ 296 | public function it_returns_the_content_type_for_a_variant_if_it_is_stored_with_a_different_type(): void 297 | { 298 | $model = $this->getTestModel(); 299 | 300 | $attachment = new Attachment; 301 | $attachment->setInstance($model); 302 | $attachment->setConfig(new PaperclipConfig([ 303 | 'attributes' => [ 304 | 'variants' => true, 305 | ], 306 | ])); 307 | $attachment->setName('image'); 308 | 309 | $model->setAttribute('image_file_name', 'test.png'); 310 | $model->setAttribute('image_content_type', 'image/png'); 311 | $model->setAttribute('image_variants', json_encode(['variantkey' => ['type' => 'text/plain']])); 312 | 313 | static::assertEquals('text/plain', $attachment->variantContentType('variantkey')); 314 | } 315 | 316 | /** 317 | * @test 318 | */ 319 | public function it_returns_whether_the_attachment_is_filled(): void 320 | { 321 | $model = $this->getTestModel(); 322 | 323 | $attachment = new Attachment; 324 | $attachment->setName('attachment'); 325 | $attachment->setInstance($model); 326 | 327 | static::assertFalse($attachment->exists()); 328 | 329 | $model->setAttribute('attachment', 'testing'); 330 | 331 | static::assertTrue($attachment->exists()); 332 | } 333 | 334 | 335 | // ------------------------------------------------------------------------------ 336 | // Properties 337 | // ------------------------------------------------------------------------------ 338 | 339 | /** 340 | * @test 341 | */ 342 | public function it_returns_the_created_at_attribute(): void 343 | { 344 | $model = $this->getTestModel(); 345 | $model->image_created_at = '2017-01-01 00:00:00'; 346 | 347 | $attachment = new Attachment; 348 | $attachment->setInstance($model); 349 | $attachment->setName('image'); 350 | $attachment->setConfig(new PaperclipConfig([ 351 | 'attributes' => [ 352 | 'created_at' => true, 353 | ], 354 | ])); 355 | 356 | static::assertEquals('2017-01-01 00:00:00', $attachment->createdAt()); 357 | } 358 | 359 | /** 360 | * @test 361 | */ 362 | public function it_returns_the_updated_at_attribute(): void 363 | { 364 | $model = $this->getTestModel(); 365 | $model->image_updated_at = '2017-01-01 00:00:00'; 366 | 367 | $attachment = new Attachment; 368 | $attachment->setInstance($model); 369 | $attachment->setName('image'); 370 | 371 | static::assertEquals('2017-01-01 00:00:00', $attachment->updatedAt()); 372 | } 373 | 374 | /** 375 | * @test 376 | */ 377 | public function it_returns_the_content_type_attribute(): void 378 | { 379 | $model = $this->getTestModel(); 380 | $model->image_content_type = 'video/mpeg'; 381 | 382 | $attachment = new Attachment; 383 | $attachment->setInstance($model); 384 | $attachment->setName('image'); 385 | 386 | static::assertEquals('video/mpeg', $attachment->contentType()); 387 | } 388 | 389 | /** 390 | * @test 391 | */ 392 | public function it_returns_the_size_attribute(): void 393 | { 394 | $model = $this->getTestModel(); 395 | $model->image_file_size = 333; 396 | 397 | $attachment = new Attachment; 398 | $attachment->setInstance($model); 399 | $attachment->setName('image'); 400 | 401 | static::assertEquals(333, $attachment->size()); 402 | } 403 | 404 | /** 405 | * @test 406 | */ 407 | public function it_returns_the_original_file_name_attribute(): void 408 | { 409 | $model = $this->getTestModel(); 410 | $model->image_file_name = 'test.png'; 411 | 412 | $attachment = new Attachment; 413 | $attachment->setInstance($model); 414 | $attachment->setName('image'); 415 | 416 | static::assertEquals('test.png', $attachment->originalFilename()); 417 | } 418 | 419 | /** 420 | * @test 421 | */ 422 | public function it_returns_a_configured_fallback_url_when_no_attachment_is_stored(): void 423 | { 424 | $model = $this->getTestModelWithAttachmentConfig([ 425 | 'url' => 'http://fallback-test-url', 426 | 'urls' => [ 427 | 'variant' => 'http://variant-fallback-test-url', 428 | ], 429 | ]); 430 | 431 | static::assertEquals('http://fallback-test-url', $model->attachment->url()); 432 | } 433 | 434 | /** 435 | * @test 436 | */ 437 | public function it_returns_a_configured_fallback_url_for_a_variant_when_no_attachment_is_stored(): void 438 | { 439 | $model = $this->getTestModelWithAttachmentConfig([ 440 | 'url' => 'http://fallback-test-url', 441 | 'urls' => [ 442 | 'variant' => 'http://variant-fallback-test-url', 443 | ], 444 | ]); 445 | 446 | static::assertEquals('http://variant-fallback-test-url', $model->attachment->url('variant')); 447 | } 448 | 449 | 450 | /** 451 | * @return FileHandlerInterface&MockInterface 452 | */ 453 | protected function getMockHandler(): MockInterface 454 | { 455 | return Mockery::mock(FileHandlerInterface::class); 456 | } 457 | 458 | /** 459 | * @param FileHandlerInterface $handler 460 | * @return FileHandlerFactoryInterface&MockInterface 461 | */ 462 | protected function getMockHandlerFactory(FileHandlerInterface $handler): MockInterface 463 | { 464 | $mock = Mockery::mock(FileHandlerFactoryInterface::class); 465 | 466 | $mock->shouldReceive('create')->andReturn($handler); 467 | 468 | return $mock; 469 | } 470 | 471 | /** 472 | * @return InterpolatorInterface&MockInterface 473 | */ 474 | protected function getMockInterpolator(): MockInterface 475 | { 476 | return Mockery::mock(InterpolatorInterface::class); 477 | } 478 | 479 | } 480 | -------------------------------------------------------------------------------- /tests/Config/PaperclipConfigTest.php: -------------------------------------------------------------------------------- 1 | getOriginalConfig()); 24 | static::assertEquals(['variants' => []], $config->toArray()); 25 | } 26 | 27 | /** 28 | * @test 29 | */ 30 | public function it_takes_array_data_and_returns_configuration_values(): void 31 | { 32 | $callable = fn () => true; 33 | 34 | $config = new PaperclipConfig([ 35 | 'attributes' => [ 36 | 'size' => false, 37 | 'content_type' => false, 38 | 'updated_at' => false, 39 | 'created_at' => false, 40 | 'variants' => false, 41 | ], 42 | 'variants' => [ 43 | 'one' => [ 44 | 'resize' => [ 45 | 'dimensions' => '50x50', 46 | ], 47 | ], 48 | ], 49 | 'extensions' => [ 50 | 'one' => 'png', 51 | ], 52 | 'types' => [ 53 | 'one' => 'image/png', 54 | ], 55 | 'keep-old-files' => true, 56 | 'preserve-files' => true, 57 | 'storage' => 'some-disk', 58 | 'path' => '/relative/path', 59 | 'variant-path' => '/relative/path/:variant', 60 | 'url' => 'default-url', 61 | 'urls' => [ 62 | 'one' => 'default-url-variant', 63 | ], 64 | 'before' => CallableClass::class . '@callMe', 65 | 'after' => $callable, 66 | ]); 67 | 68 | 69 | static::assertEquals('image/png', $config->variantMimeType('one')); 70 | static::assertEquals('some-disk', $config->storageDisk()); 71 | static::assertEquals('/relative/path', $config->path()); 72 | static::assertEquals('/relative/path/:variant', $config->variantPath()); 73 | static::assertEquals('default-url', $config->defaultUrl()); 74 | static::assertEquals('default-url-variant', $config->defaultVariantUrl('one')); 75 | 76 | static::assertEquals(['resize' => ['dimensions' => '50x50']], $config->variantConfig('one')); 77 | static::assertTrue($config->hasVariantConfig('one')); 78 | static::assertFalse($config->hasVariantConfig('does-not-exist')); 79 | 80 | static::assertEquals('png', $config->variantExtension('one')); 81 | static::assertEquals(['one' => 'png'], $config->variantExtensions()); 82 | 83 | static::assertEquals(CallableClass::class . '@callMe', $config->beforeCallable()); 84 | static::assertSame($callable, $config->afterCallable()); 85 | 86 | static::assertFalse($config->sizeAttribute()); 87 | static::assertFalse($config->contentTypeAttribute()); 88 | static::assertFalse($config->createdAtAttribute()); 89 | static::assertFalse($config->updatedAtAttribute()); 90 | static::assertFalse($config->variantsAttribute()); 91 | static::assertFalse($config->attributeProperty('created_at')); 92 | 93 | static::assertTrue($config->keepOldFiles()); 94 | static::assertTrue($config->preserveFiles()); 95 | } 96 | 97 | /** 98 | * @test 99 | */ 100 | public function it_keeps_the_auto_orient_and_resize_steps_in_the_right_order(): void 101 | { 102 | $config = new PaperclipConfig([ 103 | 'variants' => [ 104 | Variant::make('testing')->steps([ 105 | AutoOrientStep::make(), 106 | ResizeStep::make()->width(480)->height(270)->crop(), 107 | ]), 108 | ], 109 | ]); 110 | 111 | $stepKeys = array_keys($config->variantConfig('testing')); 112 | static::assertEquals(['auto-orient', 'resize'], $stepKeys); 113 | } 114 | 115 | /** 116 | * @test 117 | */ 118 | public function it_uses_default_global_variants_if_no_variants_are_configured(): void 119 | { 120 | $this->app['config']->set('paperclip.variants.default', [ 121 | 'one' => [ 122 | 'resize' => [ 123 | 'dimensions' => '50x50', 124 | ], 125 | ], 126 | ]); 127 | 128 | $config = new PaperclipConfig([ 129 | 'types' => [ 130 | 'one' => 'image/png', 131 | ], 132 | ]); 133 | 134 | static::assertTrue($config->hasVariantConfig('one')); 135 | 136 | static::assertEquals('image/png', $config->variantMimeType('one')); 137 | } 138 | 139 | /** 140 | * @test 141 | */ 142 | public function it_merges_in_default_global_variants_that_are_not_configured_if_merge_default_enabled(): void 143 | { 144 | $this->app['config']->set('paperclip.variants.merge-default', true); 145 | $this->app['config']->set('paperclip.variants.default', [ 146 | 'four' => [ 147 | 'resize' => [ 148 | 'dimensions' => '50x50', 149 | ], 150 | ], 151 | 'two' => [ 152 | 'resize' => [ 153 | 'dimensions' => '50x50', 154 | ], 155 | ], 156 | 'three' => [ 157 | 'resize' => [ 158 | 'dimensions' => '50x50', 159 | ], 160 | ], 161 | ]); 162 | 163 | $config = new PaperclipConfig([ 164 | 'variants' => [ 165 | 'one' => [ 166 | 'resize' => [ 167 | 'dimensions' => '50x50', 168 | ], 169 | ], 170 | // test whether variant set in attachment config does not get overrule by global 171 | 'three' => [ 172 | 'resize' => [ 173 | 'dimensions' => '100x100', 174 | ], 175 | ], 176 | // test whether variant set by object config gets handled correctly 177 | (new Variant('four'))->steps([ 178 | (new ResizeStep())->square(100), 179 | ]), 180 | ], 181 | ]); 182 | 183 | static::assertTrue($config->hasVariantConfig('one')); 184 | static::assertTrue($config->hasVariantConfig('two')); 185 | static::assertTrue($config->hasVariantConfig('three')); 186 | static::assertTrue($config->hasVariantConfig('four')); 187 | 188 | static::assertEquals( 189 | '100x100', 190 | $config->variantConfig('three')['resize']['dimensions'], 191 | "Variant 'three' should not be overridden by global configuration" 192 | ); 193 | static::assertEquals( 194 | '100x100', 195 | $config->variantConfig('four')['resize']['dimensions'], 196 | "Variant 'four' should not be overridden by global configuration" 197 | ); 198 | } 199 | 200 | /** 201 | * @test 202 | */ 203 | public function it_does_not_merge_in_default_global_variants_that_included_as_a_literal_false(): void 204 | { 205 | $this->app['config']->set('paperclip.variants.merge-default', true); 206 | $this->app['config']->set('paperclip.variants.default', [ 207 | 'two' => [ 208 | 'resize' => [ 209 | 'dimensions' => '50x50', 210 | ], 211 | ], 212 | ]); 213 | 214 | $config = new PaperclipConfig([ 215 | 'variants' => [ 216 | 'one' => [ 217 | 'resize' => [ 218 | 'dimensions' => '50x50', 219 | ], 220 | ], 221 | // test whether a variant set globally is left out when set to literal false on attachment 222 | 'two' => false, 223 | // test whether any old variant defined by literal false is ignored 224 | 'three' => false, 225 | ], 226 | ]); 227 | 228 | static::assertTrue($config->hasVariantConfig('one')); 229 | static::assertFalse($config->hasVariantConfig('two')); 230 | static::assertFalse($config->hasVariantConfig('three')); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /tests/Config/Steps/AutoOrientStepTest.php: -------------------------------------------------------------------------------- 1 | toArray(); 19 | 20 | static::assertIsArray($array); 21 | static::assertEquals( 22 | [ 23 | 'auto-orient' => [ 24 | 'quiet' => false, 25 | ], 26 | ], $array 27 | ); 28 | 29 | // Specific 30 | $array = AutoOrientStep::make('testname')->quiet()->toArray(); 31 | 32 | static::assertIsArray($array); 33 | static::assertEquals( 34 | [ 35 | 'testname' => [ 36 | 'quiet' => true, 37 | ], 38 | ], $array 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Config/Steps/ResizeStepTest.php: -------------------------------------------------------------------------------- 1 | width(100)->toArray(); 20 | 21 | static::assertIsArray($array); 22 | static::assertEquals( 23 | [ 24 | 'resize' => [ 25 | 'dimensions' => '100x', 26 | 'convertOptions' => [], 27 | ], 28 | ], $array 29 | ); 30 | 31 | $array = ResizeStep::make()->height(100)->toArray(); 32 | 33 | static::assertIsArray($array); 34 | static::assertEquals( 35 | [ 36 | 'resize' => [ 37 | 'dimensions' => 'x100', 38 | 'convertOptions' => [], 39 | ], 40 | ], $array 41 | ); 42 | 43 | // Specific with ignore 44 | $array = ResizeStep::make('testname')->width(100)->height(150) 45 | ->ignoreRatio() 46 | ->toArray(); 47 | 48 | static::assertEquals( 49 | [ 50 | 'testname' => [ 51 | 'dimensions' => '100x150!', 52 | 'convertOptions' => [], 53 | ], 54 | ], $array 55 | ); 56 | 57 | // Specific with crop 58 | $array = ResizeStep::make('testname')->width(150)->height(100) 59 | ->crop() 60 | ->toArray(); 61 | 62 | static::assertEquals( 63 | [ 64 | 'testname' => [ 65 | 'dimensions' => '150x100#', 66 | 'convertOptions' => [], 67 | ], 68 | ], $array 69 | ); 70 | } 71 | 72 | /** 73 | * @test 74 | */ 75 | public function it_takes_width_and_height_using_square_method(): void 76 | { 77 | $array = ResizeStep::make()->square(100)->toArray(); 78 | 79 | static::assertEquals( 80 | [ 81 | 'resize' => [ 82 | 'dimensions' => '100x100', 83 | 'convertOptions' => [], 84 | ], 85 | ], $array 86 | ); 87 | } 88 | 89 | /** 90 | * @test 91 | */ 92 | public function it_throws_an_exception_if_neither_width_nor_height_are_set(): void 93 | { 94 | $this->expectException(BadMethodCallException::class); 95 | 96 | ResizeStep::make()->toArray(); 97 | } 98 | 99 | /** 100 | * @test 101 | */ 102 | public function it_throws_an_exception_if_width_and_height_are_not_both_set_when_using_crop(): void 103 | { 104 | $this->expectException(BadMethodCallException::class); 105 | 106 | ResizeStep::make()->width(100)->crop()->toArray(); 107 | } 108 | 109 | /** 110 | * @test 111 | */ 112 | public function it_throws_an_exception_if_width_and_height_are_not_both_set_when_using_ignore_ratio(): void 113 | { 114 | $this->expectException(BadMethodCallException::class); 115 | 116 | ResizeStep::make()->height(100)->ignoreRatio()->toArray(); 117 | } 118 | 119 | /** 120 | * @test 121 | */ 122 | public function it_throws_an_exception_if_corp_and_ignore_ratio_are_both_set(): void 123 | { 124 | $this->expectException(BadMethodCallException::class); 125 | 126 | ResizeStep::make()->height(100)->width(150)->crop()->ignoreRatio()->toArray(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Config/VariantTest.php: -------------------------------------------------------------------------------- 1 | steps(['auto-orient' => []]) 19 | ->extension('txt'); 20 | 21 | static::assertEquals('thumb', $config->getName()); 22 | static::assertEquals(['auto-orient' => []], $config->getSteps()); 23 | static::assertEquals('txt', $config->getExtension()); 24 | } 25 | 26 | /** 27 | * @test 28 | */ 29 | public function it_takes_non_array_steps_data(): void 30 | { 31 | $config = Variant::make('thumb')->steps('auto-orient'); 32 | 33 | static::assertEquals(['auto-orient'], $config->getSteps()); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | public function it_takes_an_extension_and_strips_the_starting_period(): void 40 | { 41 | $config = Variant::make('test')->extension('.txt'); 42 | 43 | static::assertEquals('txt', $config->getExtension()); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function it_returns_null_for_extension_by_default(): void 50 | { 51 | $config = Variant::make('test'); 52 | 53 | static::assertNull($config->getExtension()); 54 | } 55 | 56 | /** 57 | * @test 58 | */ 59 | public function it_takes_and_returns_null_for_extension(): void 60 | { 61 | $config = Variant::make('test')->extension(null); 62 | 63 | static::assertNull($config->getExtension()); 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function it_takes_a_url(): void 70 | { 71 | $config = Variant::make('test')->url('http://www.doesnotexist.com'); 72 | 73 | static::assertEquals('http://www.doesnotexist.com', $config->getUrl()); 74 | } 75 | 76 | /** 77 | * @test 78 | */ 79 | public function it_returns_null_for_url_by_default(): void 80 | { 81 | $config = Variant::make('test'); 82 | 83 | static::assertNull($config->getUrl()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Helpers/CallableClass.php: -------------------------------------------------------------------------------- 1 | hookMethodCalled = true; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Helpers/Model/TestModel.php: -------------------------------------------------------------------------------- 1 | $attributes 44 | * @param array $attachmentConfig 45 | */ 46 | public function __construct(array $attributes = [], array $attachmentConfig = []) 47 | { 48 | $this->hasAttachedFile('attachment', $attachmentConfig); 49 | 50 | $this->hasAttachedFile('image', [ 51 | 'variants' => [ 52 | 'medium' => [ 53 | 'resize' => [ 54 | 'dimensions' => '300x300', 55 | ], 56 | ], 57 | ], 58 | ]); 59 | 60 | parent::__construct($attributes); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Helpers/VariantStrategies/TestNoChangesStrategy.php: -------------------------------------------------------------------------------- 1 | file->setMimeType('text/html'); 19 | $this->file->setName('source.htm'); 20 | 21 | return null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Integration/PaperclipAttachmentStaplerCompatibilityTest.php: -------------------------------------------------------------------------------- 1 | app['config']->set('paperclip.config.mode', 'stapler'); 25 | } 26 | 27 | 28 | /** 29 | * @test 30 | */ 31 | public function it_uses_stapler_styles_key_for_variants(): void 32 | { 33 | $attachment = new Attachment; 34 | $attachment->setConfig(new StaplerConfig([ 35 | 'styles' => [ 36 | 'some' => '100x100', 37 | 'variant' => '50x30', 38 | 'keys' => '40x', 39 | ], 40 | ])); 41 | 42 | static::assertEquals(['some', 'variant', 'keys'], $attachment->variants()); 43 | } 44 | 45 | /** 46 | * @test 47 | */ 48 | public function it_accepts_stapler_styles_and_resizes_configuration(): void 49 | { 50 | $model = $this->getTestModelWithAttachmentConfig([ 51 | 'styles' => [ 52 | 'a' => '50x50', 53 | 'b' => [ 54 | 'dimensions' => '40x40', 55 | 'auto_orient' => true, 56 | ], 57 | ], 58 | ]); 59 | 60 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath('empty.gif'))); 61 | $model->save(); 62 | 63 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'empty.gif'); 64 | $processedFilePathVariantA = $this->getUploadedAttachmentPath($model, 'empty.gif', 'attachment', 'a'); 65 | $processedFilePathVariantB = $this->getUploadedAttachmentPath($model, 'empty.gif', 'attachment', 'b'); 66 | 67 | static::assertFileExists($processedFilePath, 'Original file not stored'); 68 | static::assertFileExists($processedFilePathVariantA, 'Variant A not stored'); 69 | static::assertFileExists($processedFilePathVariantB, 'Variant B not stored'); 70 | 71 | $model->delete(); 72 | } 73 | 74 | /** 75 | * @test 76 | */ 77 | public function it_accepts_stapler_config_keys_and_normalizes_them_to_paperclip(): void 78 | { 79 | $model = $this->getTestModelWithAttachmentConfig([ 80 | 'url' => 'test/path/for-model/original/:filename', 81 | ]); 82 | 83 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath())); 84 | 85 | static::assertEquals('test/path/for-model/original/source.txt', $model->attachment->variantPath()); 86 | } 87 | 88 | /** 89 | * @test 90 | */ 91 | public function its_attachments_return_normalized_config(): void 92 | { 93 | $model = $this->getTestModelWithAttachmentConfig([ 94 | 'url' => 'test/path/for-model', 95 | 'preserve_files' => true, 96 | 'keep_old_files' => true, 97 | 'styles' => [ 98 | 'a' => '50x50', 99 | 'b' => [ 100 | 'dimensions' => '40x40', 101 | 'auto_orient' => true, 102 | ], 103 | ], 104 | ]); 105 | 106 | $config = $model->attachment->getNormalizedConfig(); 107 | 108 | static::assertArrayHasKey('path', $config); 109 | static::assertArrayNotHasKey('url', $config); 110 | 111 | static::assertArrayHasKey('preserve-files', $config); 112 | static::assertArrayNotHasKey('preserve_files', $config); 113 | 114 | static::assertArrayHasKey('keep-old-files', $config); 115 | static::assertArrayNotHasKey('keep_old_files', $config); 116 | 117 | static::assertArrayHasKey('variants', $config); 118 | static::assertArrayNotHasKey('styles', $config); 119 | 120 | static::assertArrayHasKey('a', $config['variants']); 121 | static::assertArrayHasKey('resize', $config['variants']['a'], 'Resize step was not extracted'); 122 | static::assertArrayHasKey('b', $config['variants']); 123 | static::assertArrayHasKey('auto-orient', $config['variants']['b'], 'Auto orient step was not extracted'); 124 | static::assertArrayHasKey('resize', $config['variants']['b'], 'Resize step was not extracted'); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/Integration/PaperclipConfigurationErrorsTest.php: -------------------------------------------------------------------------------- 1 | expectException(RuntimeException::class); 18 | $this->expectExceptionMessageMatches("/paperclip storage disk 'paperclip' is not configured/i"); 19 | 20 | $this->app['config']->set('filesystems.disks', []); 21 | 22 | $this->getTestModel(); 23 | } 24 | 25 | /** 26 | * @test 27 | */ 28 | public function it_throws_an_exception_if_the_paperclip_storage_disk_is_not_set(): void 29 | { 30 | $this->expectException(RuntimeException::class); 31 | $this->expectExceptionMessageMatches('/paperclip storage disk invalid or null/i'); 32 | 33 | $this->app['config']->set('filesystems.default', null); 34 | $this->app['config']->set('paperclip.storage.disk', null); 35 | 36 | $this->getTestModel(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Integration/PaperclipFluentConfigurationTest.php: -------------------------------------------------------------------------------- 1 | getTestModelWithAttachmentConfig([ 26 | 'variants' => [ 27 | 'test' => [ 28 | AutoOrientStep::make()->quiet(), 29 | ResizeStep::make()->width(100)->height(100) 30 | ->ignoreRatio() 31 | ->convertOptions(['quality' => 90]), 32 | ], 33 | ], 34 | ]); 35 | 36 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath('rotated.jpg'))); 37 | $model->save(); 38 | 39 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'rotated.jpg'); 40 | 41 | static::assertInstanceOf(Attachment::class, $model->attachment); 42 | static::assertEquals('rotated.jpg', $model->attachment_file_name); 43 | static::assertFileExists($processedFilePath, 'File was not stored'); 44 | 45 | static::assertEquals( 46 | [ 47 | 'variants' => [ 48 | 'test' => [ 49 | 'resize' => [ 50 | 'dimensions' => '100x100!', 51 | 'convertOptions' => [ 52 | 'quality' => 90, 53 | ], 54 | ], 55 | 'auto-orient' => [ 56 | 'quiet' => true, 57 | ], 58 | ], 59 | ], 60 | ], 61 | $model->attachment->getNormalizedConfig() 62 | ); 63 | 64 | if (file_exists($processedFilePath)) { 65 | unlink($processedFilePath); 66 | } 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | public function it_processes_and_stores_a_new_png_file_with_fluent_variant_steps_configuration(): void 73 | { 74 | $model = $this->getTestModelWithAttachmentConfig([ 75 | 'variants' => [ 76 | 'test' => [ 77 | AutoOrientStep::make()->quiet(), 78 | ResizeStep::make()->width(100)->height(100) 79 | ->ignoreRatio() 80 | ->convertOptions(['quality' => 90]), 81 | ], 82 | ], 83 | ]); 84 | 85 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath('picture.png'))); 86 | $model->save(); 87 | 88 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'picture.png'); 89 | 90 | static::assertInstanceOf(Attachment::class, $model->attachment); 91 | static::assertEquals('picture.png', $model->attachment_file_name); 92 | static::assertFileExists($processedFilePath, 'File was not stored'); 93 | 94 | static::assertEquals( 95 | [ 96 | 'variants' => [ 97 | 'test' => [ 98 | 'resize' => [ 99 | 'dimensions' => '100x100!', 100 | 'convertOptions' => [ 101 | 'quality' => 90, 102 | ], 103 | ], 104 | 'auto-orient' => [ 105 | 'quiet' => true, 106 | ], 107 | ], 108 | ], 109 | ], 110 | $model->attachment->getNormalizedConfig() 111 | ); 112 | 113 | if (file_exists($processedFilePath)) { 114 | unlink($processedFilePath); 115 | } 116 | } 117 | 118 | /** 119 | * @test 120 | */ 121 | public function it_processes_and_stores_a_new_file_with_fluent_variant_configuration(): void 122 | { 123 | $model = $this->getTestModelWithAttachmentConfig([ 124 | 'variants' => [ 125 | Variant::make('test') 126 | ->steps([ 127 | AutoOrientStep::make()->quiet(), 128 | ResizeStep::make()->width(100)->height(100) 129 | ->ignoreRatio() 130 | ->convertOptions(['quality' => 90]), 131 | ]) 132 | ->extension('jpg') 133 | ->url('http://test'), 134 | ], 135 | ]); 136 | 137 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath('rotated.jpg'))); 138 | $model->save(); 139 | 140 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'rotated.jpg'); 141 | 142 | static::assertInstanceOf(Attachment::class, $model->attachment); 143 | static::assertEquals('rotated.jpg', $model->attachment_file_name); 144 | static::assertFileExists($processedFilePath, 'File was not stored'); 145 | 146 | static::assertEquals( 147 | [ 148 | 'variants' => [ 149 | 'test' => [ 150 | 'resize' => [ 151 | 'dimensions' => '100x100!', 152 | 'convertOptions' => [ 153 | 'quality' => 90, 154 | ], 155 | ], 156 | 'auto-orient' => [ 157 | 'quiet' => true, 158 | ], 159 | ], 160 | ], 161 | 'extensions' => [ 162 | 'test' => 'jpg', 163 | ], 164 | 'urls' => [ 165 | 'test' => 'http://test', 166 | ], 167 | ], 168 | $model->attachment->getNormalizedConfig() 169 | ); 170 | 171 | if (file_exists($processedFilePath)) { 172 | unlink($processedFilePath); 173 | } 174 | } 175 | 176 | /** 177 | * @test 178 | */ 179 | public function it_processes_and_stores_a_new_file_with_fluent_variant_steps_configuration_without_wrapped_array(): void 180 | { 181 | $model = $this->getTestModelWithAttachmentConfig([ 182 | 'variants' => [ 183 | 'test' => AutoOrientStep::make()->quiet() 184 | ], 185 | ]); 186 | 187 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath('rotated.jpg'))); 188 | $model->save(); 189 | 190 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'rotated.jpg'); 191 | 192 | static::assertInstanceOf(Attachment::class, $model->attachment); 193 | static::assertEquals('rotated.jpg', $model->attachment_file_name); 194 | static::assertFileExists($processedFilePath, 'File was not stored'); 195 | 196 | static::assertEquals( 197 | [ 198 | 'variants' => [ 199 | 'test' => [ 200 | 'auto-orient' => [ 201 | 'quiet' => true, 202 | ], 203 | ], 204 | ], 205 | ], 206 | $model->attachment->getNormalizedConfig() 207 | ); 208 | 209 | if (file_exists($processedFilePath)) { 210 | unlink($processedFilePath); 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /tests/Integration/PaperclipReprocessAttachmentTest.php: -------------------------------------------------------------------------------- 1 | getTestModel(); 30 | 31 | static::assertFalse($model->image->exists()); 32 | 33 | $model->image->reprocess(); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | public function it_reprocesses_variants(): void 40 | { 41 | $model = $this->getTestModel(); 42 | 43 | $model->setAttribute('image', new SplFileInfo($this->getTestFilePath('empty.gif'))); 44 | $model->save(); 45 | 46 | static::assertTrue($model->image->exists()); 47 | static::assertEquals('empty.gif', $model->image_file_name); 48 | 49 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'empty.gif', 'image', 'medium'); 50 | 51 | static::assertFileExists($processedFilePath, 'Variant file does not exist'); 52 | 53 | 54 | // Delete the uploaded file, so we can see if it gets rewritten on reprocessing 55 | unlink($processedFilePath); 56 | static::assertFileDoesNotExist($processedFilePath, 'Variant file should not exist after unlinking'); 57 | 58 | 59 | $this->prepareMockSetupForReprocessingSource($model); 60 | 61 | $model->image->reprocess(); 62 | 63 | static::assertFileExists($processedFilePath, 'Variant file does not exist after refresh'); 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function it_processes_a_variant_updating_the_model_variants_attribute(): void 70 | { 71 | $this->app['config']->set('paperclip.variants.aliases.test-html', TestTextToHtmlStrategy::class); 72 | 73 | $model = $this->getTestModelWithAttachmentConfig([ 74 | 'attributes' => [ 75 | 'variants' => true, 76 | ], 77 | 'variants' => [ 78 | 'test' => [ 79 | 'test-html' => [], 80 | ], 81 | ], 82 | ]); 83 | 84 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath('empty.gif'))); 85 | $model->save(); 86 | 87 | $expectedVariantsInformation = [ 88 | 'test' => [ 89 | 'ext' => 'htm', 90 | 'type' => 'text/html', 91 | ] 92 | ]; 93 | 94 | static::assertEquals($expectedVariantsInformation, $model->attachment->variantsAttribute()); 95 | 96 | $model->attachment_variants = null; 97 | $model->save(); 98 | 99 | static::assertEmpty($model->attachment->variantsAttribute(), 'Variants should be empty for test'); 100 | 101 | $this->prepareMockSetupForReprocessingSource($model, 'attachment'); 102 | 103 | // Test 104 | $model->attachment->reprocess(); 105 | 106 | static::assertEquals( 107 | $expectedVariantsInformation, 108 | $model->attachment->variantsAttribute(), 109 | 'Variant information not rewritten after reprocessing' 110 | ); 111 | } 112 | 113 | /** 114 | * @test 115 | */ 116 | public function it_reprocesses_a_variant_that_does_not_change_the_file_with_variants_attribute_enabled(): void 117 | { 118 | $this->app['config']->set('paperclip.variants.aliases.test-same', TestNoChangesStrategy::class); 119 | 120 | $model = $this->getTestModelWithAttachmentConfig([ 121 | 'attributes' => [ 122 | 'variants' => true, 123 | ], 124 | 'variants' => [ 125 | 'test' => [ 126 | 'test-same' => [], 127 | ], 128 | ], 129 | ]); 130 | 131 | $model->setAttribute('attachment', new SplFileInfo($this->getTestFilePath('empty.gif'))); 132 | $model->save(); 133 | 134 | static::assertEquals([], $model->attachment->variantsAttribute()); 135 | 136 | $model->attachment_variants = null; 137 | $model->save(); 138 | 139 | static::assertEmpty($model->attachment->variantsAttribute(), 'Variants should be empty for test'); 140 | 141 | $this->prepareMockSetupForReprocessingSource($model, 'attachment'); 142 | 143 | // Test 144 | $model->attachment->reprocess(); 145 | 146 | static::assertEquals( 147 | [], 148 | $model->attachment->variantsAttribute(), 149 | 'Variant information not rewritten after reprocessing' 150 | ); 151 | } 152 | 153 | /** 154 | * @test 155 | */ 156 | public function it_fires_an_event_when_something_goes_wrong_while_reprocessing_a_variant(): void 157 | { 158 | Event::fake(); 159 | 160 | $model = $this->getTestModel(); 161 | 162 | 163 | $model->setAttribute('image', new SplFileInfo($this->getTestFilePath('empty.gif'))); 164 | $model->save(); 165 | 166 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'empty.gif', 'image'); 167 | 168 | static::assertFileExists($processedFilePath, 'Original file does not exist'); 169 | 170 | // Delete the original file, so reprocessing fails 171 | unlink($processedFilePath); 172 | static::assertFileDoesNotExist($processedFilePath, 'File should not exist after unlinking'); 173 | 174 | 175 | $this->prepareMockSetupForReprocessingException($model); 176 | 177 | $model->image->reprocess(); 178 | 179 | Event::assertDispatched(ProcessingExceptionEvent::class); 180 | } 181 | 182 | /** 183 | * @test 184 | */ 185 | public function it_throws_an_exception_when_something_goes_wrong_while_reprocessing_a_variant_when_configured_to(): void 186 | { 187 | // Disable event firing so the exception is thrown. 188 | $this->app['config']->set('paperclip.processing.errors.events', false); 189 | 190 | $this->expectException(VariantProcessFailureException::class); 191 | $this->expectExceptionMessageMatches("#failed to process variant 'medium'#i"); 192 | 193 | $model = $this->getTestModel(); 194 | 195 | $model->setAttribute('image', new SplFileInfo($this->getTestFilePath('empty.gif'))); 196 | $model->save(); 197 | 198 | $processedFilePath = $this->getUploadedAttachmentPath($model, 'empty.gif', 'image'); 199 | 200 | static::assertFileExists($processedFilePath, 'Original file does not exist'); 201 | 202 | // Delete the original file, so reprocessing fails 203 | unlink($processedFilePath); 204 | static::assertFileDoesNotExist($processedFilePath, 'File should not exist after unlinking'); 205 | 206 | 207 | $this->prepareMockSetupForReprocessingException($model); 208 | 209 | $model->image->reprocess(); 210 | } 211 | 212 | 213 | protected function prepareMockSetupForReprocessingSource( 214 | Model $model, 215 | string $attachment = 'image', 216 | bool $withExpectation = true, 217 | ): void { 218 | /** @var StorableFileFactoryInterface&MockInterface $factory */ 219 | $factory = Mockery::mock(StorableFileFactoryInterface::class); 220 | 221 | $source = $this->getSourceForReprocessing($this->getTestFilePath('empty.gif')); 222 | 223 | if ($withExpectation) { 224 | $factory->shouldReceive('makeFromUrl') 225 | ->once() 226 | ->with($model->{$attachment}->url(), 'empty.gif', 'image/gif') 227 | ->andReturn($source); 228 | } else { 229 | $factory->shouldReceive('makeFromUrl') 230 | ->with($model->{$attachment}->url(), 'empty.gif', 'image/gif') 231 | ->andReturn($source); 232 | } 233 | 234 | app()->instance(StorableFileFactoryInterface::class, $factory); 235 | } 236 | 237 | protected function prepareMockSetupForReprocessingException(Model $model, string $attachment = 'image'): void 238 | { 239 | /** @var StorableFileFactoryInterface&MockInterface $factory */ 240 | $factory = Mockery::mock(StorableFileFactoryInterface::class); 241 | 242 | $exception = new Exception('testing'); 243 | 244 | $file = Mockery::mock(StorableFileInterface::class); 245 | $file->shouldReceive('extension')->andThrow($exception); 246 | $file->shouldReceive('path')->andThrow($exception); 247 | 248 | $factory->shouldReceive('makeFromUrl') 249 | ->once() 250 | ->with($model->{$attachment}->url(), 'empty.gif', 'image/gif') 251 | ->andReturn($file); 252 | 253 | app()->instance(StorableFileFactoryInterface::class, $factory); 254 | } 255 | 256 | protected function getSourceForReprocessing( 257 | string $path, 258 | string $name = 'empty.gif', 259 | string $type = 'image/gif', 260 | ): SplFileInfoStorableFile { 261 | $source = new SplFileInfoStorableFile(); 262 | $source->setData(new SplFileInfo($path)); 263 | $source->setName($name); 264 | $source->setMimeType($type); 265 | 266 | return $source; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /tests/Path/InterpolatingTargetTest.php: -------------------------------------------------------------------------------- 1 | getMockInterpolator(); 23 | 24 | $interpolator->shouldReceive('interpolate') 25 | ->once() 26 | ->with('base/original/:filename', Matchers::any(AttachmentDataInterface::class)) 27 | ->andReturn('base/original/testing.txt'); 28 | 29 | $interpolator->shouldReceive('interpolate') 30 | ->once() 31 | ->with('base/variants/:variant/:filename', Matchers::any(AttachmentDataInterface::class), 'variant') 32 | ->andReturn('base/variants/variant/testing.txt'); 33 | 34 | $target = new InterpolatingTarget( 35 | $interpolator, 36 | $this->getMockAttachmentData(), 37 | 'base/original/:filename', 38 | 'base/variants/:variant/:filename' 39 | ); 40 | 41 | static::assertEquals('base/original/testing.txt', $target->original()); 42 | static::assertEquals('base/variants/variant/testing.txt', $target->variant('variant')); 43 | } 44 | 45 | /** 46 | * @return InterpolatorInterface&MockInterface 47 | */ 48 | protected function getMockInterpolator(): MockInterface 49 | { 50 | return Mockery::mock(InterpolatorInterface::class); 51 | } 52 | 53 | /** 54 | * @return AttachmentDataInterface&MockInterface 55 | */ 56 | protected function getMockAttachmentData(): MockInterface 57 | { 58 | return Mockery::mock(AttachmentDataInterface::class); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Path/InterpolatorTest.php: -------------------------------------------------------------------------------- 1 | getMockAttachmentData(); 24 | $attachment->shouldReceive('name')->once()->andReturn('attributename'); 25 | $attachment->shouldReceive('getInstanceKey')->andReturn(13); 26 | $attachment->shouldReceive('getInstanceClass')->once()->andReturn('App\\Models\\Test'); 27 | 28 | $result = $interpolator->interpolate(':class/:id_partition/:attribute', $attachment, 'variant'); 29 | 30 | static::assertEquals('App/Models/Test/000/000/013/attributename', $result); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function it_interpolates_filename(): void 37 | { 38 | $interpolator = new Interpolator; 39 | 40 | $attachment = $this->getMockAttachmentData(); 41 | $attachment->shouldReceive('variantFilename')->once()->with('variant') 42 | ->andReturn('testing.gif'); 43 | 44 | $result = $interpolator->interpolate('test/:filename', $attachment, 'variant'); 45 | 46 | static::assertEquals('test/testing.gif', $result); 47 | } 48 | 49 | /** 50 | * @test 51 | */ 52 | public function it_interpolates_app_root(): void 53 | { 54 | $interpolator = new Interpolator; 55 | 56 | $attachment = $this->getMockAttachmentData(); 57 | 58 | $result = $interpolator->interpolate(':app_root', $attachment, 'variant'); 59 | 60 | static::assertEquals(app_path(), $result); 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function it_interpolates_class(): void 67 | { 68 | $interpolator = new Interpolator; 69 | 70 | $attachment = $this->getMockAttachmentData(); 71 | $attachment->shouldReceive('getInstanceClass')->twice()->andReturn('App\\TestClass\\Name'); 72 | 73 | $result = $interpolator->interpolate(':class/:class_name', $attachment, 'variant'); 74 | 75 | static::assertEquals('App/TestClass/Name/Name', $result); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function it_interpolates_namespace(): void 82 | { 83 | $interpolator = new Interpolator; 84 | 85 | $attachment = $this->getMockAttachmentData(); 86 | $attachment->shouldReceive('getInstanceClass')->once()->andReturn('App\\TestClass\\Name'); 87 | 88 | $result = $interpolator->interpolate(':namespace', $attachment, 'variant'); 89 | 90 | static::assertEquals('App/TestClass', $result); 91 | } 92 | 93 | /** 94 | * @test 95 | */ 96 | public function it_interpolates_attribute_name(): void 97 | { 98 | $interpolator = new Interpolator; 99 | 100 | $attachment = $this->getMockAttachmentData(); 101 | $attachment->shouldReceive('name')->once()->andReturn('attribute'); 102 | 103 | $result = $interpolator->interpolate(':name/test', $attachment, 'variant'); 104 | 105 | static::assertEquals('attribute/test', $result); 106 | } 107 | 108 | /** 109 | * @test 110 | */ 111 | public function it_interpolates_basename(): void 112 | { 113 | $interpolator = new Interpolator; 114 | 115 | $attachment = $this->getMockAttachmentData(); 116 | $attachment->shouldReceive('variantFilename')->once()->with('variant') 117 | ->andReturn('testing.txt'); 118 | 119 | $result = $interpolator->interpolate(':basename/test', $attachment, 'variant'); 120 | 121 | static::assertEquals('testing/test', $result); 122 | } 123 | 124 | /** 125 | * @test 126 | */ 127 | public function it_interpolates_extension(): void 128 | { 129 | $interpolator = new Interpolator; 130 | 131 | $attachment = $this->getMockAttachmentData(); 132 | $attachment->shouldReceive('variantFilename')->once()->with('variant') 133 | ->andReturn('testing.txt'); 134 | 135 | $result = $interpolator->interpolate(':extension/test', $attachment, 'variant'); 136 | 137 | static::assertEquals('txt/test', $result); 138 | } 139 | 140 | /** 141 | * @test 142 | */ 143 | public function it_interpolates_secure_hash(): void 144 | { 145 | $interpolator = new Interpolator; 146 | 147 | $attachment = $this->getMockAttachmentData(); 148 | $attachment->shouldReceive('getInstanceKey')->andReturn(13); 149 | $attachment->shouldReceive('size')->once()->andReturn(333); 150 | $attachment->shouldReceive('variantFilename')->once()->with('variant') 151 | ->andReturn('testing.txt'); 152 | 153 | $result = $interpolator->interpolate(':secure_hash', $attachment, 'variant'); 154 | 155 | static::assertEquals('b7e89900b301888e5e9035e2117a36642e5ef4330389e0fde88db7009007908d', $result); 156 | } 157 | 158 | /** 159 | * @test 160 | */ 161 | public function it_interpolates_hash(): void 162 | { 163 | $interpolator = new Interpolator; 164 | 165 | $attachment = $this->getMockAttachmentData(); 166 | $attachment->shouldReceive('getInstanceKey')->andReturn(13); 167 | 168 | $result = $interpolator->interpolate(':hash', $attachment, 'variant'); 169 | 170 | static::assertEquals('3fdba35f04dc8c462986c992bcf875546257113072a909c162f7e470e581e278', $result); 171 | } 172 | 173 | /** 174 | * @test 175 | */ 176 | public function it_interpolates_id_partion_for_string_id(): void 177 | { 178 | $interpolator = new Interpolator; 179 | 180 | $attachment = $this->getMockAttachmentData(); 181 | $attachment->shouldReceive('getInstanceKey')->andReturn('astring'); 182 | 183 | $result = $interpolator->interpolate(':id_partition', $attachment, 'variant'); 184 | 185 | static::assertEquals('ast/rin/g', $result); 186 | } 187 | 188 | /** 189 | * @test 190 | */ 191 | public function it_interpolates_id_partion_for_string_id_with_control_characters(): void 192 | { 193 | $interpolator = new Interpolator; 194 | 195 | $attachment = $this->getMockAttachmentData(); 196 | $attachment->shouldReceive('getInstanceKey')->andReturn("astring\n\t"); 197 | 198 | $result = $interpolator->interpolate(':id_partition', $attachment, 'variant'); 199 | 200 | static::assertEquals('ec3/1c8/43d', $result); 201 | } 202 | 203 | /** 204 | * @test 205 | */ 206 | public function it_interpolates_attachment(): void 207 | { 208 | $interpolator = new Interpolator; 209 | 210 | $attachment = $this->getMockAttachmentData(); 211 | $attachment->shouldReceive('name')->andReturn('image'); 212 | 213 | $result = $interpolator->interpolate(':attachment', $attachment, 'variant'); 214 | 215 | static::assertEquals('images', $result); 216 | } 217 | 218 | /** 219 | * @test 220 | */ 221 | public function it_interpolates_style_for_variant(): void 222 | { 223 | $interpolator = new Interpolator; 224 | 225 | $attachment = $this->getMockAttachmentData(); 226 | 227 | $result = $interpolator->interpolate(':style', $attachment, 'variant'); 228 | 229 | static::assertEquals('variant', $result); 230 | } 231 | 232 | /** 233 | * @test 234 | */ 235 | public function it_interpolates_style_for_original(): void 236 | { 237 | $interpolator = new Interpolator; 238 | 239 | $attachment = $this->getMockAttachmentData(); 240 | $attachment->shouldReceive('getConfig')->once()->andReturn(['default-variant' => 'original']); 241 | 242 | $result = $interpolator->interpolate(':style', $attachment); 243 | 244 | static::assertEquals('original', $result); 245 | } 246 | 247 | 248 | /** 249 | * @return AttachmentDataInterface&MockInterface 250 | */ 251 | protected function getMockAttachmentData(): MockInterface 252 | { 253 | return Mockery::mock(AttachmentDataInterface::class); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /tests/ProvisionedTestCase.php: -------------------------------------------------------------------------------- 1 | getBasePath() . '/public/paperclip/'; 19 | } 20 | 21 | protected function getUploadedAttachmentPath( 22 | Model $model, 23 | string $file = 'source.txt', 24 | string $attachmentName = 'attachment', 25 | string $variant = 'original', 26 | ): string { 27 | return $this->getBasePaperclipPath() 28 | . str_replace('\\', '/', get_class($model)) 29 | . '/000/000/' . (str_pad((string) $model->getKey(), 3, '0', STR_PAD_LEFT)) 30 | . '/' . $attachmentName 31 | . '/' . $variant 32 | . '/' . $file; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 20 | } 21 | 22 | 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | protected function getEnvironmentSetUp($app) 27 | { 28 | /** @var Repository $config */ 29 | $config = $app['config']; 30 | 31 | $config->set('paperclip', include(realpath(dirname(__DIR__) . '/config/paperclip.php'))); 32 | 33 | $config->set('database.default', 'testbench'); 34 | $config->set('database.connections.testbench', $this->getDatabaseConfigForSqlite()); 35 | 36 | $config->set('filesystems.disks.paperclip', [ 37 | 'driver' => 'local', 38 | 'root' => $this->getBasePath() . '/public/paperclip', 39 | 'visibility' => 'public', 40 | ]); 41 | } 42 | 43 | /** 44 | * {@inheritDoc} 45 | */ 46 | protected function getPackageProviders($app): array 47 | { 48 | return [ 49 | PaperclipServiceProvider::class, 50 | ]; 51 | } 52 | 53 | /** 54 | * Returns the testing config for a (shared) SQLite connection. 55 | * 56 | * @return array 57 | */ 58 | protected function getDatabaseConfigForSqlite(): array 59 | { 60 | return [ 61 | 'driver' => 'sqlite', 62 | 'database' => ':memory:', 63 | 'prefix' => '', 64 | ]; 65 | } 66 | 67 | /** 68 | * Sets up the database for testing. This includes migration and standard seeding. 69 | */ 70 | protected function setUpDatabase(): void 71 | { 72 | Schema::create('test_models', function (Blueprint $table) { 73 | $table->increments('id'); 74 | $table->string('name', 255)->nullable(); 75 | $table->string('attachment_file_name', 255)->nullable(); 76 | $table->integer('attachment_file_size')->nullable(); 77 | $table->string('attachment_content_type')->nullable(); 78 | $table->timestamp('attachment_updated_at')->nullable(); 79 | $table->string('attachment_variants', 255)->nullable(); 80 | $table->string('image_file_name', 255)->nullable(); 81 | $table->integer('image_file_size')->nullable(); 82 | $table->string('image_content_type')->nullable(); 83 | $table->timestamp('image_updated_at')->nullable(); 84 | $table->nullableTimestamps(); 85 | }); 86 | } 87 | 88 | protected function getTestModel(): TestModel 89 | { 90 | return TestModel::create(['name' => 'Testing']); 91 | } 92 | 93 | /** 94 | * @param array $attachmentConfig 95 | * @return TestModel 96 | */ 97 | protected function getTestModelWithAttachmentConfig(array $attachmentConfig): TestModel 98 | { 99 | $model = new TestModel([], $attachmentConfig); 100 | $model->save(); 101 | 102 | return $model; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/resources/alternative.txt: -------------------------------------------------------------------------------- 1 | alternative text file for testing 2 | -------------------------------------------------------------------------------- /tests/resources/empty.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czim/laravel-paperclip/036e42dccb34170155cb71a0aeb42a29afcdc964/tests/resources/empty.gif -------------------------------------------------------------------------------- /tests/resources/picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czim/laravel-paperclip/036e42dccb34170155cb71a0aeb42a29afcdc964/tests/resources/picture.png -------------------------------------------------------------------------------- /tests/resources/rotated.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czim/laravel-paperclip/036e42dccb34170155cb71a0aeb42a29afcdc964/tests/resources/rotated.jpg -------------------------------------------------------------------------------- /tests/resources/source.txt: -------------------------------------------------------------------------------- 1 | source text file for testing 2 | --------------------------------------------------------------------------------