├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── config.php └── src ├── Events ├── Event.php ├── HandledDeleted.php ├── HandledSaving.php ├── HandlingDeleted.php └── HandlingSaving.php ├── Facade.php ├── Helpers.php ├── Manager.php ├── Observer.php ├── ServiceProvider.php ├── Storage.php └── SupportsUploads.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 BKWLD 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Upchuck 2 | 3 | [![Packagist](https://img.shields.io/packagist/v/bkwld/upchuck.svg)](https://packagist.org/packages/bkwld/upchuck) 4 | 5 | Upchuck is a simple, automatic handler of file uploads for [Laravel's](http://laravel.com/) [Eloquent](http://laravel.com/docs/eloquent) models using using [Flysystem](http://flysystem.thephpleague.com/). It does not attempt to do anything besides let the developer treat file uploads like regular input fields. It does this by listening to Eloquent `saving` events, checking the model attribute for `UploadedFile` instances, pushing those files to "disk" of your choosing, and then storing the publically accessible URL in the model attribute for that input. 6 | 7 | 8 | ## Installation 9 | 10 | 1. Add to your project: `composer require bkwld/upchuck:~2.0` 11 | 2. *Laravel < 5.5 only* Add Upchuck as a provider in your app/config/app.php's provider list: `'Bkwld\Upchuck\ServiceProvider',` 12 | 3. Publish the config: `php artisan vendor:publish --provider="Bkwld\Upchuck\ServiceProvider"` 13 | 14 | 15 | ## Usage 16 | 17 | Edit the `disk` config setting to supply configuration information for where uploads should be moved. We are using [Graham Campbell's Flysystem](https://github.com/GrahamCampbell/Laravel-Flysystem) integration for Laravel to instantiate Flysystem instances, so the configruation of the `disk` matches his [configuration options for connections](https://github.com/GrahamCampbell/Laravel-Flysystem/blob/1.0/src/config/config.php#L38). As the comments in the config file mention, I recommend turning on caching if you are using any disk other than `local`. For both [caching](https://github.com/thephpleague/flysystem-cached-adapter) and [other disk drivers](https://github.com/thephpleague/flysystem#adapters), you will need to include other packages. 18 | 19 | Then, to enable upload support for your models, use the `Bkwld\Upchuck\SupportsUploads` trait on your model and itemize each attribute that should support uploads via the `$upload_attributes` property. For example: 20 | 21 | ```php 22 | class Person extends Eloquent { 23 | 24 | // Use the trait 25 | use Bkwld\Upchuck\SupportsUploads; 26 | 27 | // Define the uploadable attributes 28 | protected $upload_attributes = [ 'image', 'pdf', ]; 29 | 30 | // Since the upload handling happens via model events, it acts like a mass 31 | // assignment. As such, Upchuck sets attributes via `fill()` so you can 32 | // control the setting. 33 | protected $fillable = ['image', 'pdf']; 34 | } 35 | ``` 36 | 37 | Then, say you have a `` field, you would do this from your controller: 38 | 39 | ```php 40 | $model = new Model; 41 | $model->fill(Input::all()) 42 | $model->save(); 43 | ``` 44 | 45 | You are filling the object with the `Input:all()` array, which includes your image data as an `UploadedFile` object keyed to the `image` attribute. When you `save()`, Upchuck will act on the `saving` event, moving the upload into the storage you've defined in the config file, and replacing the attribute value with the URL of the file. 46 | 47 | 48 | ### Resizing images 49 | 50 | If your app supports uploading files you are probably also dealing with needing to resize uploaded images. We (BKWLD) use our [Croppa](https://github.com/BKWLD/croppa) package to resize images using specially formatted URLs. If you are looking for an model-upload package that also resizes images, you might want to check out [Stapler](https://github.com/CodeSleeve/stapler). 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bkwld/upchuck", 3 | "description": "A simple, automatic handler of file uploads for Laravel's Eloquent models using using Flysystem.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Robert Reinhard", 8 | "email": "info@bkwld.com" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=5.5.0", 13 | "illuminate/database": "^5", 14 | "illuminate/queue": "^5", 15 | "illuminate/support": "^5", 16 | "graham-campbell/flysystem": "^3 || ^4 || ^5" 17 | }, 18 | "conflict": { 19 | "laravel/framework": "<5.4.0" 20 | }, 21 | "suggest": { 22 | "league/flysystem-cached-adapter": "Required for Flysystem caching of remote disks" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Bkwld\\Upchuck\\": "src/" 27 | } 28 | }, 29 | "extra": { 30 | "laravel": { 31 | "providers": [ 32 | "Bkwld\\Upchuck\\ServiceProvider" 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | [ 16 | 17 | /** 18 | * Local exaample 19 | */ 20 | 'driver' => 'local', 21 | 'path' => public_path('uploads'), 22 | 'visibility' => 'public', 23 | 24 | /** 25 | * AWS S3 example 26 | */ 27 | // 'driver' => 'awss3', 28 | // 'key' => 'your-key', 29 | // 'secret' => 'your-secret', 30 | // 'bucket' => 'your-bucket', 31 | // 'prefix' => 'uploads/', 32 | // 'visibility' => 'public', 33 | 34 | ], 35 | 36 | /** 37 | * Enable Flysystem caching using Laravel's current cache provider. You must 38 | * require Flysystem's cache adapter package, league/flysystem-cached-adapter, 39 | * if enabled. You should enable this if you are using a non-local disk. 40 | * 41 | * See: http://flysystem.thephpleague.com/caching/ 42 | */ 43 | 'cache' => false, 44 | 45 | /** 46 | * A string that is prepended to the path of the upload (relative to its disk) 47 | * to convert it from a path to URL resolveable in HTML. 48 | */ 49 | 'url_prefix' => '/uploads/', 50 | // 'url_prefix' => 'https://your-bucket.s3.amazonaws.com/uploads/', 51 | 52 | /** 53 | * Whether to delete files when a model is soft deleted. 54 | */ 55 | 'keep_files_when_soft_deleted' => false, 56 | 57 | /** 58 | * How deep to nest files within subdirectories 59 | */ 60 | 'depth' => 2, 61 | 62 | /** 63 | * How many folders will be created in each depth 64 | */ 65 | 'length' => 16, 66 | ]; 67 | -------------------------------------------------------------------------------- /src/Events/Event.php: -------------------------------------------------------------------------------- 1 | model = $model; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Events/HandledDeleted.php: -------------------------------------------------------------------------------- 1 | url($path); 13 | } 14 | 15 | /** 16 | * Return the Flysystem remote disk as the main facade so 17 | * the remote disk can be easily interacted with 18 | * 19 | * @return string 20 | */ 21 | protected static function getFacadeAccessor() { 22 | return 'upchuck.disk'; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Helpers.php: -------------------------------------------------------------------------------- 1 | config = $config; 20 | } 21 | 22 | /** 23 | * Check whether Upchuck manages the given URL 24 | * 25 | * @param string $url 26 | * @return boolean 27 | */ 28 | public function manages($url) { 29 | return preg_match('#^'.$this->config['url_prefix'].'#', $url) > 0; 30 | } 31 | 32 | /** 33 | * Get the path on the disk given the URL. 34 | * 35 | * @param string $url 36 | * @return string 37 | */ 38 | public function path($url) { 39 | $prefix = $this->config['url_prefix']; 40 | 41 | // If the url_prefix is absolute-path style but the url isn't, get only the 42 | // path from the URL before comparing against the prefix. 43 | if (preg_match('#^/#', $prefix) && preg_match('#^http#', $url)) { 44 | $url = parse_url($url, PHP_URL_PATH); 45 | } 46 | 47 | // Trim the prefix from the URL 48 | return substr($url, strlen($prefix)); 49 | } 50 | 51 | /** 52 | * Get a URL of an upload given the path to an asset 53 | * 54 | * @param string $path 55 | * @return string 56 | */ 57 | public function url($path) { 58 | return $this->config['url_prefix'].ltrim($path, '/'); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/Manager.php: -------------------------------------------------------------------------------- 1 | config->get($this->getConfigName().'.disk'); 34 | 35 | // Add cache info the config 36 | if ($this->config->get($this->getConfigName().'.cache')) { 37 | $config['cache'] = $this->getCacheConfig(); 38 | } 39 | 40 | // Use the driver as the name. 41 | $config['name'] = $config['driver']; 42 | 43 | // Return adapter config in the format GrahamCampbell/Flysystem expects 44 | return $config; 45 | } 46 | 47 | /** 48 | * Get the cache configuration. Upchuck only uses Illuminate caching. 49 | * 50 | * @param string $name Not used but part of parent 51 | * @throws InvalidArgumentException 52 | * @return array 53 | */ 54 | protected function getCacheConfig(string $name = null) { 55 | return [ 56 | 'name' => 'illuminate', 57 | 'driver' => 'illuminate', 58 | 'key' => 'upchuck', 59 | ]; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/Observer.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 31 | $this->config = $config; 32 | } 33 | 34 | /** 35 | * A model is saving, check for files being uploaded 36 | * 37 | * @param string $event 38 | * @param array $payload containg: 39 | * - Illuminate\Database\Eloquent\Model 40 | * @return void 41 | */ 42 | public function onSaving($event, $payload) { 43 | 44 | // Destructure params 45 | list($model) = $payload; 46 | 47 | // Check that the model supports uploads through Upchuck 48 | if (!$this->supportsUploads($model) 49 | || !($attributes = $model->getUploadAttributes())) return; 50 | 51 | // Loop through the all of the upload attributes ... 52 | event(new Events\HandlingSaving($model)); 53 | foreach($attributes as $attribute) { 54 | 55 | // Check if there is an uploaded file in the upload attribute 56 | if (($file = $model->getAttribute($attribute)) 57 | && is_a($file, UploadedFile::class)) { 58 | 59 | // Move the upload and get the new URL 60 | $url = $this->storage->moveUpload($file); 61 | $model->setUploadAttribute($attribute, $url); 62 | } 63 | 64 | // If the attribute field is dirty, delete the old file 65 | if ($model->isDirty($attribute) 66 | && !$this->keepsFilesOnDelete($model) 67 | && ($old = $model->getOriginal($attribute))) { 68 | $this->storage->delete($old); 69 | } 70 | } 71 | event(new Events\HandledSaving($model)); 72 | } 73 | 74 | /** 75 | * A model has been deleted, trash all of it's files 76 | * 77 | * @param string $event 78 | * @param array $payload containg: 79 | * - Illuminate\Database\Eloquent\Model 80 | * @return void 81 | */ 82 | public function onDeleted($event, $payload) { 83 | 84 | // Destructure params 85 | list($model) = $payload; 86 | 87 | // If the model is soft deleted and the config states to NOT delete if 88 | // soft deleted, abort here. 89 | if ($this->keepsFilesOnDelete($model)) return; 90 | 91 | // Check that the model supports uploads through Upchuck 92 | if (!$this->supportsUploads($model) 93 | || !($attributes = $model->getUploadAttributes())) return; 94 | 95 | // Loop through the all of the upload attributes and get the values using 96 | // "original" so that you get the file value before it may have been cleared. 97 | event(new Events\HandlingDeleted($model)); 98 | foreach($attributes as $attribute) { 99 | if (!$url = $model->getOriginal($attribute)) continue; 100 | $this->storage->delete($url); 101 | } 102 | event(new Events\HandledDeleted($model)); 103 | } 104 | 105 | /** 106 | * Should the model not delete files on delete 107 | * 108 | * @param Illuminate\Database\Eloquent\Model $model 109 | * @return boolean 110 | */ 111 | public function keepsFilesOnDelete($model) 112 | { 113 | return in_array(SoftDeletes::class, class_uses_recursive($model)) 114 | && !empty($this->config['keep_files_when_soft_deleted']); 115 | } 116 | 117 | /** 118 | * Check that the model supports uploads through Upchuck. Not detecting the 119 | * trait because it doesn't report to subclasses. 120 | * 121 | * @param Illuminate\Database\Eloquent\Model $model 122 | * @return boolean 123 | */ 124 | public function supportsUploads($model) { 125 | return method_exists($model, 'getUploadAttributes'); 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 23 | __DIR__.'/../config/config.php' => config_path('upchuck.php') 24 | ], 'upchuck'); 25 | 26 | // Listen for Eloquent saving and deleting 27 | $this->app['events']->listen('eloquent.saving:*', 'upchuck.observer@onSaving'); 28 | $this->app['events']->listen('eloquent.deleted:*', 'upchuck.observer@onDeleted'); 29 | 30 | } 31 | 32 | /** 33 | * Register the service provider. 34 | * 35 | * @return void 36 | */ 37 | public function register() { 38 | 39 | // Merges package config with user config 40 | $this->mergeConfigFrom(__DIR__.'/../config/config.php', 'upchuck'); 41 | 42 | // Instantiate helpers 43 | $this->app->singleton('upchuck', function($app) { 44 | return new Helpers($app['config']->get('upchuck')); 45 | }); 46 | 47 | // Instantiate the disk for the tmp directory, where the image was uploaded 48 | $this->app->singleton('upchuck.tmp', function($app) { 49 | $tmp = ini_get('upload_tmp_dir') ?: sys_get_temp_dir(); 50 | return new Filesystem(new LocalAdapter($tmp)); 51 | }); 52 | 53 | // Instantiate the disk for the destination 54 | $this->app->singleton('upchuck.disk', function($app) { 55 | 56 | // Build GrahamCampbell\Flysystem's factory for making Flysystem instances 57 | $adapter = new AdapterFactory(); 58 | $cache = new CacheFactory($app['cache']); 59 | $factory = new FlysystemFactory($adapter, $cache); 60 | 61 | // Make an instance of this package's subclass of GrahamCampbell\Flysystem's 62 | // Manager class that creates connections given configs. 63 | $manager = new Manager($app['config'], $factory); 64 | 65 | // Massage the Upchuck config to what GrahamCampbell\Flysystem is expecting 66 | return $factory->make($manager->getConnectionConfig(), $manager); 67 | }); 68 | 69 | // Instantiate Flysystem's manager for this package 70 | $this->app->singleton('upchuck.manager', function($app) { 71 | return new MountManager([ 72 | 'tmp' => $app['upchuck.tmp'], 73 | 'disk' => $app['upchuck.disk'], 74 | ]); 75 | }); 76 | 77 | // Instantiate observer which handles model save / delete and delegates 78 | // out the saving of files 79 | $this->app->singleton('upchuck.observer', function($app) { 80 | $config = $app['config']->get('upchuck'); 81 | return new Observer($app['upchuck.storage'], $config); 82 | }); 83 | 84 | // Instantiate storage class 85 | $this->app->singleton('upchuck.storage', function($app) { 86 | return new Storage( 87 | $app['upchuck.manager'], 88 | $app['upchuck'], 89 | $app['config']->get('upchuck.depth'), 90 | $app['config']->get('upchuck.length') 91 | ); 92 | }); 93 | 94 | } 95 | 96 | /** 97 | * Get the services provided by the provider. 98 | * 99 | * @return array 100 | */ 101 | public function provides() { 102 | return array( 103 | 'upchuck', 104 | 'upchuck.disk', 105 | 'upchuck.tmp', 106 | 'upchuck.manager', 107 | 'upchuck.observer', 108 | 'upchuck.storage', 109 | ); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/Storage.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 43 | $this->helpers = $helpers; 44 | $this->depth = $depth; 45 | $this->length = $length; 46 | } 47 | 48 | /** 49 | * Move an uploaded file from the /tmp directory of the local filesystem 50 | * to the configured location 51 | * 52 | * @param Symfony\Component\HttpFoundation\File\UploadedFile $file 53 | * @return string $url A URL to to the file, resolveable in HTML 54 | */ 55 | public function moveUpload(UploadedFile $file) { 56 | 57 | // Nest the uploaded file into unique sub directory and a unqiue name 58 | $path = $this->makeNestedAndUniquePath($file->getClientOriginalName()); 59 | 60 | // Move the uploaded file to the destination using Flysystem and return 61 | // the new path 62 | $this->manager->move('tmp://'.$file->getFilename(), 'disk://'.$path); 63 | 64 | // Return the URL of the upload. 65 | return $this->helpers->url($path); 66 | } 67 | 68 | /** 69 | * Create a unique directory and filename 70 | * 71 | * @param string $filename 72 | * @param League\Flysystem\Filesystem|void $disk 73 | * @return string New path and filename 74 | */ 75 | public function makeNestedAndUniquePath($filename, $disk = null) { 76 | 77 | // If no disk defined, get it from the current mount mananger 78 | if (empty($disk)) $disk = $this->manager->getFilesystem('disk'); 79 | 80 | // Remove unsafe characters from the filename 81 | // https://regex101.com/r/mJ3sI5/1 82 | $filename = preg_replace('#[^\w\-_\.]#i', '_', $filename); 83 | 84 | // Create nested folders to store the file in 85 | $dir = ''; 86 | for ($i=0; $i<$this->depth; $i++) { 87 | $dir .= str_pad(mt_rand(0, $this->length - 1), strlen($this->length), '0', STR_PAD_LEFT).'/'; 88 | } 89 | 90 | // If this file doesn't already exist, return it 91 | $path = $dir.$filename; 92 | if (!$disk->has($path)) return $path; 93 | 94 | // Get a unique filename for the file and return it 95 | $file = pathinfo($filename, PATHINFO_FILENAME); 96 | $i = 1; 97 | $ext = pathinfo($filename, PATHINFO_EXTENSION); 98 | while ($disk->has($path = $dir.$file.'-'.$i.'.'.$ext)) { $i++; } 99 | return $path; 100 | 101 | } 102 | 103 | /** 104 | * Delete an upload 105 | * 106 | * @param string $url A URL like was returned from moveUpload() 107 | * @return void 108 | */ 109 | public function delete($url) { 110 | 111 | // Convert to a path 112 | $path = $this->helpers->path($url); 113 | 114 | // Delete the path if it still exists 115 | if ($this->manager->has('disk://'.$path)) $this->manager->delete('disk://'.$path); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/SupportsUploads.php: -------------------------------------------------------------------------------- 1 | upload_attributes)) return []; 24 | return $this->upload_attributes; 25 | } 26 | 27 | /** 28 | * Set a model attribute for an uploaded file to the URL of that file. Uses 29 | * `fill()` so that mass assignment prevention will apply. 30 | * 31 | * @param string $attribute 32 | * @param string $url 33 | */ 34 | public function setUploadAttribute($attribute, $url) { 35 | $this->fill([$attribute => $url]); 36 | } 37 | 38 | } 39 | --------------------------------------------------------------------------------