diff --git a/docs/Tutorials/Quick-Start.md b/docs/Tutorials/Quick-Start.md index e7a251c9..e9bf6d90 100644 --- a/docs/Tutorials/Quick-Start.md +++ b/docs/Tutorials/Quick-Start.md @@ -18,21 +18,26 @@ app/Config/file_storage.php There is a good amount of code to be added to prepare everything. In theory you can put all of this in bootstrap as well but to keep things clean it is recommended to put all of this in a separate file. +This might look like a lot things to do but when this is done storing the files will work immediately and you have a *very* flexible and powerful storage system configured. + ```php -use Aws\S3; -use Burzum\FileStorage\Event\ImageProcessingListener; -use Burzum\FileStorage\Event\S3StorageListener; -use Burzum\FileStorage\Lib\FileStorageUtils; -use Burzum\FileStorage\Lib\StorageManager; +use Aws\S3\S3Client; +use Burzum\FileStorage\Storage\Listener\BaseListener; +use Burzum\FileStorage\Storage\StorageUtils; +use Burzum\FileStorage\Storage\StorageManager; use Cake\Core\Configure; use Cake\Event\EventManager; -// Attach the S3 Listener to the global EventManager -$listener = new S3StorageListener(); -EventManager::instance()->on($listener); - -// Attach the Image Processing Listener to the global EventManager -$listener = new ImageProcessingListener(); +// Instantiate a storage event listener +$listener = new BaseListener( + 'imageProcessing' => true, // Required if you want image processing! + 'pathBuilderOptions' => [ + // Preserves the original filename in the storage backend. + // Otherwise it would use a UUID as filename by default. + 'preserveFilename' => true + ] +); +// Attach the BaseListener to the global EventManager EventManager::instance()->on($listener); Configure::write('FileStorage', [ @@ -65,27 +70,48 @@ Configure::write('FileStorage', [ ]); // This is very important! The hashes are needed to calculate the image versions! -FileStorageUtils::generateHashes(); - -// Optional, lets use the AwsS3 adapter here instead of local -$S3Client = \Aws\S3\S3Client::factory([ - 'key' => 'YOUR-KEY', - 'secret' => 'YOUR-SECRET' - ]); +StorageUtils::generateHashes(); + +// Lets use the Amazon S3 adapter here instead of the default `Local` config. +// We need to pass a S3Client instance to this adapter to make it work +$S3Client = new S3Client([ + 'version' => 'latest', + 'region' => 'eu-central-1', + 'credentials' => [ + 'key' => 'YOUR-AWS-S3-KEY-HERE', + 'secret' => 'YOUR-SECRET-HERE' + ] +]); -// Configure the Gaufrette adapter through the StorageManager -StorageManager::config('S3Image', [ - 'adapterOptions' => [ +// Configure the S3 adapter instance through the StorageManager +StorageManager::config('S3', [ + 'adapterOptions' => array( $S3Client, - 'YOUR-BUCKET-NAME', + 'YOUR-BUCKET-NAME-HERE', // Bucket [], true - ], + ), 'adapterClass' => '\Gaufrette\Adapter\AwsS3', 'class' => '\Gaufrette\Filesystem' ]); ``` +If you did everything right you can now run this command from your app: + +```sh +bin/cake storage store --adapter S3 +``` + +If you did everything right your should see some output like this: + +If you're not familiar with the CakePHP shell and running into problems with the shell, not the plugin itself, please [read this](http://book.cakephp.org/3.0/en/console-and-shells.html) first! + +``` +File successfully saved! +UUID: ebb21e79-029d-441d-8f2e-d8c20ca8f5a9 +Path: file_storage/18/ef/b4/ebb21e79029d441d8f2ed8c20ca8f5a9/ +``` + **It is highly recommended to read the following sections to understand how this works.** * [Included Event Listeners](../Documentation/Included-Event-Listeners.md) diff --git a/src/Shell/StorageShell.php b/src/Shell/StorageShell.php index f94ad91d..c943957c 100644 --- a/src/Shell/StorageShell.php +++ b/src/Shell/StorageShell.php @@ -7,6 +7,8 @@ namespace Burzum\FileStorage\Shell; use Cake\Console\Shell; +use Burzum\FileStorage\Storage\StorageUtils; +use Burzum\FileStorage\Storage\StorageManager; class StorageShell extends Shell { @@ -23,10 +25,59 @@ public function main() {} public function getOptionParser() { $parser = parent::getOptionParser(); + $parser->addOption('adapter', [ + 'short' => 'a', + 'help' => __('The adapter config name to use.'), + 'default' => 'Local' + ]); + $parser->addOption('model', [ + 'short' => 'm', + 'help' => __('The model / table to use.'), + 'default' => 'Burzum/FileStorage.FileStorage' + ]); $parser->addSubcommand('image', [ 'help' => __('Image Processing Task.'), 'parser' => $this->Image->getOptionParser() ]); + $parser->addSubcommand('store', [ + 'help' => __('Stores a file in the DB.'), + ]); return $parser; } + + /** + * Store a local file via command line in any storage backend. + * + * @return void + */ + public function store() { + $model = $this->loadModel($this->params['model']); + if (empty($this->args[0])) { + $this->error('No file provided!'); + } + + if (!file_exists($this->args[0])) { + $this->error('The file does not exist!'); + } + + $adapterConfig = StorageManager::config($this->params['adapter']); + if (empty($adapterConfig)) { + $this->error(sprintf('Invalid adapter config `%s` provided!', $this->params['adapter'])); + } + + $fileData = StorageUtils::fileToUploadArray($this->args[0]); + $entity = $model->newEntity([ + 'adapter' => $this->params['adapter'], + 'file' => $fileData, + 'filename' => $fileData['name'] + ]); + + if ($model->save($entity)) { + $this->out('File successfully saved!'); + $this->out('UUID: ' . $entity->id); + $this->out('Path: ' . $entity->path()); + } else { + $this->error('Failed to save the file.'); + } + } } diff --git a/src/Storage/Listener/AbstractListener.php b/src/Storage/Listener/AbstractListener.php index 82631683..d3c1fae7 100644 --- a/src/Storage/Listener/AbstractListener.php +++ b/src/Storage/Listener/AbstractListener.php @@ -132,15 +132,20 @@ public function implementedEvents() { * Check if the event is of a type or subject object of type model we want to * process with this listener. * - * @throws \InvalidArgumentException * @param Event $event - * @return boolean + * @return bool + * @throws \Burzum\FileStorage\Storage\StorageException */ protected function _checkEvent(Event $event) { + $className = $this->_getAdapterClassFromConfig($event->data['record']['adapter']); + $classes = $this->_adapterClasses; + if (!empty($classes) && !in_array($className, $this->_adapterClasses)) { + $message = 'The listener `%s` doesn\'t allow the `%s` adapter class! Probably because it can\'t work with it.'; + throw new StorageException(sprintf($message, get_class($this), $className)); + } return ( isset($event->data['table']) && $event->data['table'] instanceof Table - && $this->getAdapterClassName($event->data['record']['adapter']) && $this->_modelFilter($event) ); } diff --git a/src/Storage/Listener/BaseListener.php b/src/Storage/Listener/BaseListener.php new file mode 100644 index 00000000..c4459919 --- /dev/null +++ b/src/Storage/Listener/BaseListener.php @@ -0,0 +1,209 @@ + 'Base', + 'pathBuilderOptions' => [ + 'modelFolder' => true, + ], + 'fileHash' => false, + 'imageProcessing' => false, + ]; + + /** + * List of adapter classes the event listener can work with. + * + * It is used in FileStorageEventListenerBase::getAdapterClassName to get + * the class, to detect if an event passed to this listener should be + * processed or not. + * + * By default the base listener will NOT check if the listener and it's + * path builder configuration is compatible with any provided storage + * backend! + * + * @var array + */ + public $_adapterClasses = []; + + /** + * Implemented Events + * + * @return array + */ + public function implementedEvents() { + return array_merge(parent::implementedEvents(), [ + 'FileStorage.afterSave' => 'afterSave', + 'FileStorage.afterDelete' => 'afterDelete', + 'ImageStorage.afterSave' => 'afterSave', + 'ImageStorage.afterDelete' => 'afterDelete', + 'ImageVersion.removeVersion' => 'removeImageVersion', + 'ImageVersion.createVersion' => 'createImageVersion', + 'ImageVersion.getVersions' => 'imagePath', + 'FileStorage.ImageHelper.imagePath' => 'imagePath', // deprecated + 'FileStorage.getPath' => 'getPath' // deprecated + ]); + } + + /** + * File removal is handled AFTER the database record was deleted. + * + * No need to use an adapter here, just delete the whole folder using cakes Folder class + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @return void + */ + public function afterDelete(Event $event, EntityInterface $entity) { + if ($this->_checkEvent($event)) { + $event->result = $this->_deleteFile($event);; + $event->stopPropagation(); + } + } + + /** + * Save the file to the storage backend after the record was created. + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @return void + */ + public function afterSave(Event $event, EntityInterface $entity) { + if ($this->_checkEvent($event) && $entity->isNew()) { + $fileField = $this->config('fileField'); + + $entity['hash'] = $this->getFileHash($entity, $fileField); + $entity['path'] = $this->pathBuilder()->fullPath($entity); + + if (!$this->_storeFile($event)) { + return; + } + + if ($this->_config['imageProcessing'] === true) { + $this->autoProcessImageVersions($entity, 'create'); + } + + $event->result = true; + $event->stopPropagation(); + } + } + + /** + * Generates the path the image url / path for viewing it in a browser depending on the storage adapter + * + * @param \Cake\Event\Event $event + * @throws \InvalidArgumentException + * @return void + */ + public function imagePath(Event $event) { + $data = $event->data + [ + 'image' => null, + 'version' => null, + 'options' => [], + 'pathType' => 'fullPath' + ]; + + if ($event->subject() instanceof EntityInterface) { + $data['image'] = $event->subject(); + } + + $entity = $data['image']; + $version = $data['version']; + $options = $data['options']; + $type = $data['pathType']; + + if (!$entity) { + throw new \InvalidArgumentException('No image entity provided.'); + } + + $this->_loadImageProcessingFromConfig(); + $event->data['path'] = $event->result = $this->imageVersionPath($entity, $version, $type, $options); + $event->stopPropagation(); + } + + /** + * Removes a specific image version. + * + * @param \Cake\Event\Event $event + * @return void + */ + public function removeImageVersion(Event $event) { + $this->_processImages($event, 'removeImageVersions'); + } + + /** + * Creates the versions for an image. + * + * @param \Cake\Event\Event $event + * @return void + */ + public function createImageVersion(Event $event) { + $this->_processImages($event, 'createImageVersions'); + } + + /** + * @param \Cake\Event\Event $event + * @param string $method + * return void + */ + protected function _processImages(Event $event, $method) { + if ($this->config('imageProcessing') !== true) { + return; + } + + $versions = $this->_getVersionData($event); + $options = isset($event->data['options']) ? $event->data['options'] : []; + + $this->_loadImageProcessingFromConfig(); + $event->result = $this->{$method}( + $event->data['record'], + $versions, + $options + ); + } + + /** + * This method retrieves version names from event data. + * + * For backward compatibility version names are resolved from operations data keys because in old + * ImageProcessingListener operations were required in event data. ImageProcessingTrait need only + * version names so operations can be read from the config. + * + * @param \Cake\Event\Event $event + * @return array + */ + protected function _getVersionData($event) + { + if (isset($event->data['versions'])) { + $versions = $event->data['versions']; + } elseif (isset($event->data['operations'])) { + $versions = array_keys($event->data['operations']); + } else { + $versions = []; + } + + return $versions; + } +} diff --git a/src/Storage/Listener/LocalListener.php b/src/Storage/Listener/LocalListener.php index e2f0d057..030f71a9 100644 --- a/src/Storage/Listener/LocalListener.php +++ b/src/Storage/Listener/LocalListener.php @@ -6,11 +6,6 @@ */ namespace Burzum\FileStorage\Storage\Listener; -use Burzum\FileStorage\Storage\StorageException; -use Cake\Datasource\EntityInterface; -use Cake\Event\Event; -use Psr\Log\LogLevel; - /** * Local FileStorage Event Listener for the CakePHP FileStorage plugin * @@ -18,23 +13,7 @@ * @author Tomenko Yegeny * @license MIT */ -class LocalListener extends AbstractListener { - - use ImageProcessingTrait; - - /** - * Default settings - * - * @var array - */ - protected $_defaultConfig = [ - 'pathBuilder' => 'Base', - 'pathBuilderOptions' => [ - 'modelFolder' => true, - ], - 'fileHash' => false, - 'imageProcessing' => false, - ]; +class LocalListener extends BaseListener { /** * List of adapter classes the event listener can work with. @@ -44,169 +23,13 @@ class LocalListener extends AbstractListener { * not. Only events with an adapter class present in this array will be * processed. * + * The LocalListener will ONLY work with the '\Gaufrette\Adapter\Local' + * adapter for backward compatiblity reasons for now. Use the BaseListener + * or extend this one here and add your adapter classes. + * * @var array */ public $_adapterClasses = [ '\Gaufrette\Adapter\Local' ]; - - /** - * Implemented Events - * - * @return array - */ - public function implementedEvents() { - return array_merge(parent::implementedEvents(), [ - 'FileStorage.afterSave' => 'afterSave', - 'FileStorage.afterDelete' => 'afterDelete', - 'ImageStorage.afterSave' => 'afterSave', - 'ImageStorage.afterDelete' => 'afterDelete', - 'ImageVersion.removeVersion' => 'removeImageVersion', - 'ImageVersion.createVersion' => 'createImageVersion', - 'ImageVersion.getVersions' => 'imagePath', - 'FileStorage.ImageHelper.imagePath' => 'imagePath', // deprecated - 'FileStorage.getPath' => 'getPath' // deprecated - ]); - } - - /** - * File removal is handled AFTER the database record was deleted. - * - * No need to use an adapter here, just delete the whole folder using cakes Folder class - * - * @param \Cake\Event\Event $event - * @param \Cake\Datasource\EntityInterface $entity - * @return void - */ - public function afterDelete(Event $event, EntityInterface $entity) { - if ($this->_checkEvent($event)) { - $event->result = $this->_deleteFile($event);; - $event->stopPropagation(); - } - } - - /** - * Save the file to the storage backend after the record was created. - * - * @param \Cake\Event\Event $event - * @param \Cake\Datasource\EntityInterface $entity - * @return void - */ - public function afterSave(Event $event, EntityInterface $entity) { - if ($this->_checkEvent($event) && $entity->isNew()) { - $fileField = $this->config('fileField'); - - $entity['hash'] = $this->getFileHash($entity, $fileField); - $entity['path'] = $this->pathBuilder()->fullPath($entity); - - if (!$this->_storeFile($event)) { - return; - } - - if ($this->_config['imageProcessing'] === true) { - $options = isset($event->data['options']) ? $event->data['options'] : []; - $this->autoProcessImageVersions($entity, 'create'); - } - - $event->result = true; - $event->stopPropagation(); - } - } - - /** - * Generates the path the image url / path for viewing it in a browser depending on the storage adapter - * - * @param \Cake\Event\Event $event - * @throws \InvalidArgumentException - * @return void - */ - public function imagePath(Event $event) { - $data = $event->data + [ - 'image' => null, - 'version' => null, - 'options' => [], - 'pathType' => 'fullPath' - ]; - - if ($event->subject() instanceof EntityInterface) { - $data['image'] = $event->subject(); - } - - $entity = $data['image']; - $version = $data['version']; - $options = $data['options']; - $type = $data['pathType']; - - if (!$entity) { - throw new \InvalidArgumentException('No image entity provided.'); - } - - $this->_loadImageProcessingFromConfig(); - $event->data['path'] = $event->result = $this->imageVersionPath($entity, $version, $type, $options); - $event->stopPropagation(); - } - - /** - * Removes a specific image version. - * - * @param \Cake\Event\Event $event - * @return void - */ - public function removeImageVersion(Event $event) { - $this->_processImages($event, 'removeImageVersions'); - } - - /** - * Creates the versions for an image. - * - * @param \Cake\Event\Event $event - * @return void - */ - public function createImageVersion(Event $event) { - $this->_processImages($event, 'createImageVersions'); - } - - /** - * @param \Cake\Event\Event $event - * @param string $method - * return void - */ - protected function _processImages(Event $event, $method) { - if ($this->config('imageProcessing') !== true) { - return; - } - - $versions = $this->_getVersionData($event); - $options = isset($event->data['options']) ? $event->data['options'] : []; - - $this->_loadImageProcessingFromConfig(); - $event->result = $this->{$method}( - $event->data['record'], - $versions, - $options - ); - } - - /** - * This method retrieves version names from event data. - * - * For backward compatibility version names are resolved from operations data keys because in old - * ImageProcessingListener operations were required in event data. ImageProcessingTrait need only - * version names so operations can be read from the config. - * - * @param \Cake\Event\Event $event - * @return array - */ - protected function _getVersionData($event) - { - if (isset($event->data['versions'])) { - $versions = $event->data['versions']; - } elseif (isset($event->data['operations'])) { - $versions = array_keys($event->data['operations']); - } else { - $versions = []; - } - - return $versions; - } }