diff --git a/.gitignore b/.gitignore index 53360d44..59405f23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /vendor /plugins -/tmp \ No newline at end of file +/tmp +/composer.lock +/nbproject diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 4f78cc93..bf8cdafb 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -5,13 +5,20 @@ checks: remove_php_closing_tag: true remove_trailing_whitespace: true tools: - external_code_coverage: - timeout: 1800 - runs: 1 php_code_coverage: false php_loc: enabled: true excluded_dirs: [vendor, tests, config, docs] php_cpd: enabled: true - excluded_dirs: [vendor, tests, config, docs] \ No newline at end of file + excluded_dirs: [vendor, tests, config, docs] +filter: + excluded_paths: [src/Event/, src/Lib/] +build: + tests: + override: + - + command: 'phpunit --coverage-clover=coverage.xml' + coverage: + file: 'coverage.xml' + format: 'php-clover' diff --git a/.travis.yml b/.travis.yml index 88ce6d35..2ba3fc16 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: php +sudo: false php: - 5.4 diff --git a/README.md b/README.md index 9559bcf5..dec888ea 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,28 @@ -FileStorage Plugin for CakePHP 2.x and 3.x -========================================== +FileStorage Plugin for CakePHP +============================== [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.txt) [![Build Status](https://img.shields.io/travis/burzum/cakephp-file-storage/3.0.svg?style=flat-square)](https://travis-ci.org/burzum/cakephp-file-storage) [![Coverage Status](https://img.shields.io/coveralls/burzum/cakephp-file-storage/3.0.svg?style=flat-square)](https://coveralls.io/r/burzum/cakephp-file-storage) +**If you're upgrading from CakePHP 2.x please read [the migration guide](docs/Documentation/Migrating-from-CakePHP-2.md).** + The **File Storage** plugin is giving you the possibility to upload and store files in virtually any kind of storage backend. This plugin is wrapping the [Gaufrette](https://github.com/KnpLabs/Gaufrette) library in a CakePHP fashion and provides a simple way to use the storage adapters through the [StorageManager](Lib/StorageManager.php) class. Storage adapters are an unified interface that allow you to store file data to your local file system, in memory, in a database or into a zip file and remote systems. There is a database table keeping track of what you stored where. You can always write your own adapter or extend and overload existing ones. -**If you're upgrading from CakePHP 2.x please read [the migration guide](docs/Documentation/Migrating-from-CakePHP-2.md).** +[Please donate if you like it!](https://pledgie.com/campaigns/29682) +------------------------------ + +Already thought of how many hours development time this plugin saved you already? It would be *awesome* if you don't mind sharing some of your success by donating a small amount! Thank you. + +How it works +------------ + +The whole plugin is build with clear [Separation of Concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) in mind: A file is *always* an entry in the `file_storage` table from the app perspective. The table is the *reference* to the real place of where the file is stored and keeps some meta information like mime type, filename, file hash (optional) and size as well. You associate the `file_storage` table with your model using the FileStorage or ImageStorage model from the plugin via hasOne, hasMany or HABTM. When you upload a file you save it to the FileStorage model through the associations, `Documents.file` for example. The FileStorage model dispatches then file storage specific events, the listeners listening to these events process the file and put it in the configured storage backend using adapters for different backends and build the storage path using a path builder class. -**List of supported Adapters** +List of supported Adapters +-------------------------- * Apc * Amazon S3 diff --git a/composer.json b/composer.json index 55c9fcc9..688b6fdb 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,6 @@ ], "minimum-stability": "dev", "require": { - "php": ">=5.4.19", "cakephp/cakephp": "~3.0", "cakephp/plugin-installer": "*", "cakephp/migrations": "~1.0", diff --git a/config/bootstrap.php b/config/bootstrap.php index e87beb5a..815cc5f3 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -1,10 +1,8 @@ on($listener); - -$listener = new LocalFileStorageListener(); +$listener = new LocalListener([ + 'imageProcessing' => true +]); EventManager::instance()->on($listener); diff --git a/docs/Documentation/Database-Setup.md b/docs/Documentation/Database-Setup.md deleted file mode 100644 index 943ffa10..00000000 --- a/docs/Documentation/Database-Setup.md +++ /dev/null @@ -1,21 +0,0 @@ -Database Setup -============== - -You need to setup the plugin database using [the official migrations plugin for CakePHP](https://github.com/cakephp/migrations). - -``` -cake migrations migrate -p Burzum/FileStorage -``` - -If you're coming from the CakePHP 2.0 version of the plugin, the support for the CakeDC Migrations plugin has been dropped in favor of the official migrations plugin. - -Integer type IDs vs UUIDs -------------------------- - -If you want to use integers instead of [UUIDs](http://en.wikipedia.org/wiki/Universally_unique_identifier) put this into your ```bootstrap.php``` *before* you're running the migrations: - -```php -Configure::write('FileStorage.schema.useIntegers', true); -``` - -This config option is **not** available for the regular CakePHP schema that comes with the plugin. It seems to be impossible to override the type on the fly. If you can figure out how to do it a pull request is welcome. diff --git a/docs/Documentation/How-To-Use.md b/docs/Documentation/How-To-Use.md index b6d1661f..45d9f429 100644 --- a/docs/Documentation/How-To-Use.md +++ b/docs/Documentation/How-To-Use.md @@ -9,8 +9,8 @@ The basic idea of this plugin is that files are always handled as separate entit This plugin resolves that issue by handling each file as a completely separate entity in the application. There is just one table `file_storage` that will keep the reference to all your files, no matter where they're stored. -How to Store an Uploaded File ------------------------------ +Preparing the File Upload +------------------------- This section is going to show how to store a file using the Storage Manager directly. @@ -20,20 +20,26 @@ For example you have a Report model and want to save a pdf to it, you would then public function initialize(array $config) { $this->hasOne('PdfFiles', [ - 'className' => 'FileStorage.PdfFiles', - 'foreignKey' => 'foreign_key' + 'className' => 'Burzum/FileStorage.PdfFiles', + 'foreignKey' => 'foreign_key', + 'conditions' => [ + 'PdfFiles.model' => 'Reports' + ] ]); } ``` -In your add.ctp or edit.ctp views you would add something like: +In your `add.ctp` or `edit.ctp` views you would add something like: ```php -echo $this->Form->input('Report.title'); -echo $this->Form->input('PdfFile.file'); -echo $this->Form->input('Report.description'); +echo $this->Form->input('title'); +echo $this->Form->input('pdf_files.file'); +echo $this->Form->input('description'); ``` +Handling the File Upload +------------------------ + **Now comes the crucial point of the whole implementation** Because of to many different requirements and personal preferences out there the plugin is *not* automatically storing the file. You'll have to customize it a little but its just a matter for a few lines. @@ -41,15 +47,16 @@ Because of to many different requirements and personal preferences out there the Lets go by this scenario inside the report model, assuming there is an add() method: ```php -$entity = $this->newEntity($data); +$entity = $this->newEntity($postData); $saved = $this->save($entity); if ($saved) { $key = 'your-file-name'; - if (StorageManager::adapter('Local')->write($key, file_get_contents($this->data['PdfFile']['file']['tmp_name']))) { - $this->data['PdfFile']['foreign_key'] = $saved->id; - $this->data['PdfFile']['model'] = 'Report'; - $this->data['PdfFile']['path'] = $key; - $this->data['PdfFile']['adapter'] = 'Local'; + if (StorageManager::adapter('Local')->write($key, file_get_contents($this->data['pdf_files']['file']['tmp_name']))) { + $postData['pdf_files']['foreign_key'] = $saved->id; + $postData['pdf_files']['model'] = 'Reports'; + $postData['pdf_files']['path'] = $key; + $postData['pdf_files']['adapter'] = 'Local'; + $this->PdfDocuments->save($this->PdfDocuments->newEntity($postData)); } } ``` @@ -69,16 +76,16 @@ The **FileStorage** plugin comes with a class that acts just as a listener to so This class will listen to all the ImageStorage model events and save the uploaded image and then create the versions for that image and storage adapter. -It is important to understand that each storage adapter requires a different handling. You can not treat a local file the same as a file you store in a cloud service. The interface that this plugin and Gaufrette provide is the same but not the internals. +It is important to understand that nearly each storage adapter requires a little different handling: Most of the time you can't treat a local file the same as a file you store in a cloud service. The interface that this plugin and Gaufrette provide is the same but not the internals. So a path that works for your local file system might not work for your remote storage system because it has other requirements or limitations. -So if you want to store a file using Amazon S3 you would have to store it, create all the versions of that image locally and then upload each of them and then delete the local temp files. +So if you want to store a file using Amazon S3 you would have to store it, create all the versions of that image locally and then upload each of them and then delete the local temp files. The good news is the plugin can already take care of that. -When you create a new listener it is important that you check the model field and the event subject object if it matches what you expect. Using the event system you could create any kind of storage and upload behavior without inheriting or touching the model code. Just write a listener class and attach it to the global CakeEventManager. +When you create a new listener it is important that you check the `model` field and the event subject object (usually a table object inheriting \Cake\ORM\Table) if it matches what you expect. Using the event system you could create any kind of storage and upload behavior without inheriting or touching the model code. Just write a listener class and attach it to the global EventManager. List of events -------------- -Events triggered in the ImageStorage model: +Events triggered in the `ImageStorage` model: * ImageVersion.createVersion * ImageVersion.removeVersion @@ -87,7 +94,7 @@ Events triggered in the ImageStorage model: * ImageStorage.beforeDelete * ImageStorage.afterDelete -Events triggered in the FileStorage model: +Events triggered in the `FileStorage` model: * FileStorage.beforeSave * FileStorage.afterSave @@ -101,14 +108,10 @@ See [this page](Included-Event-Listeners.md) for the event listeners that are in Why is it done like this? ------------------------- -Every developer might want to store the file at a different point or apply other operations on the file before or after it is store. Based on different circumstances you might want to save an associated file even before you created the record its going to get attached to, in other scenarios like in this documentation you might want to do it after. +Every developer might want to store the file at a different point or apply other operations on the file before or after it is stored. Based on different circumstances you might want to save an associated file even before you created the record its going to get attached to, in other scenarios like in this documentation you might want to do it after. The ``$key`` is also a key aspect of it: Different adapters might expect a different key. A key for the Local adapter of Gaufrette is usually a path and a file name under which the data gets stored. That's also the reason why you use `file_get_contents()` instead of simply passing the tmp path as it is. It is up to you how you want to generate the key and build your path. You can customize the way paths and file names are build by writing a custom event listener for that. It is highly recommended to read the Gaufrette documentation for the read() and write() methods of the adapters. - - - - diff --git a/docs/Documentation/How-it-works.md b/docs/Documentation/How-it-works.md new file mode 100644 index 00000000..cc973a4a --- /dev/null +++ b/docs/Documentation/How-it-works.md @@ -0,0 +1,8 @@ +How it works +------------ + +The whole plugin is build with clear [Separation of Concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) in mind: A file is *always* an entry in the `file_storage` table from the app perspective. + +The table is the *reference* to the real place of where the file is stored and keeps some meta information like mime type, filename, file hash (optional) and size as well. You associate the `file_storage` table with your model using the FileStorage or ImageStorage model from the plugin via hasOne, hasMany or HABTM. + +When you upload a file you save it to the FileStorage model through the associations, `Documents.file` for example. The FileStorage model dispatches then file storage specific events. The listeners listening to these events process the file and put it in the configured storage backend using adapters for different backends and build the storage path using a path builder class. \ No newline at end of file diff --git a/docs/Documentation/Included-Event-Listeners.md b/docs/Documentation/Included-Event-Listeners.md index ab2c7bc5..abf55aeb 100644 --- a/docs/Documentation/Included-Event-Listeners.md +++ b/docs/Documentation/Included-Event-Listeners.md @@ -1,46 +1,55 @@ Included Event Listeners ======================== -LocalFileStorageListener ------------------------- +**[For the deprecated event listeners please click here](Legacy-Event-Listeners.md)** -The file and folder structure it will generate looks like that: +--- -``` -basePath/files/xx/xx/xx//. -``` +Introduction +------------ -ImageProcessingListener ------------------------ +The included event listeners will throw a StorageException when something went wrong. It's your duty to handle them. Also you can configure a logger to the `storage` log scope to filter logs by this scope. -This listener will create versions of images if Configure::read('Media.imageSizes.' . $model); is not empty. If no processing operations for that model were specified it will just save the image. +Each listener has a configured *Path Builder*, check the [path builder documentation] to see what they do and what their purpose is. -This adapter replaces LocalImageProcessingListener and currently supports the Local and AmazonS3 adapter. +To change the path builder config for a listener check what path builder the listener is using and pass the path builder config to the constructor of the listener: + +```php +$listener = new LocalListener([ + 'pathBuilderOptions' => [ + // options go here + ] +]); +``` -The file and folder structure it will generate looks like that: +If you want to implement your own listeners you'll have to extend them from the [AbstractListener](../../src/Storage/Listener/AbstractListener.php) and implement the event callbacks. + +Local Listener +-------------- + +The local listener will store files by default in this kind of path: ``` -basePath/images/xx/xx/xx//. +////. ``` -Versioned images will look like that +Example: ``` -basePath/images/xx/xx/xx//.. +/var/www/my_app/files/Documents/05/51/68/38f684612c6f11e5a2cb0800200c9a66/38f684612c6f11e5a2cb0800200c9a66.jpg ``` - * For the Local adapter basePath is the value configured for this adapter, by default the `TMP` constant. - * For AmazonS3 the basePath will be the bucket and Amazon S3 URL prefix. - -xx stands for a semi random alphanumerical value calculated based on the given file name if the Local adapter was used. +The listener is using by default the `LocalPathBuilder` to generate the path. -**Some important notes about the path the processor generates:** +The reason for the UUID folder name is simply to ensure it is unique per file and it makes it easy to store versions of the same file in the same folder. -The path stored to the database is **not** going to be the complete path, it won't add the filename for a reason. +AWS S3 Listener +--------------- -The filename is generated by the processor on the fly when adding/deleting/modifying images because the versions are build on the fly and not stored to the database. See `ImageProcessingListener::_buildPath()`. +There is no new AWS S3 listener yet, you can either use the old legacy listener or write your own based on the new listeners. A contribution of a new listener is highly welcome! -LocalImageProcessingListener (deprecated) ------------------------------------------ +Legacy Local File Storage Listener +---------------------------------- -The LocalImageProcessingListener is **deprecated**, use ImageProcessingListener. +This listener mimics the behavior of the deprecated `LocalFileStorageEventListener`. + \ No newline at end of file diff --git a/docs/Documentation/Installation.md b/docs/Documentation/Installation.md index d2464cb7..288ff7b2 100644 --- a/docs/Documentation/Installation.md +++ b/docs/Documentation/Installation.md @@ -12,6 +12,17 @@ Installing the plugin via [Composer](https://getcomposer.org/) is very simple, j composer require burzum/file-storage:3.0.*@dev ``` +Database Setup +-------------- + +You need to setup the plugin database using [the official migrations plugin for CakePHP](https://github.com/cakephp/migrations). + +``` +cake migrations migrate -p Burzum/FileStorage +``` + +If you're coming from the CakePHP 2.0 version of the plugin, the support for the CakeDC Migrations plugin has been dropped in favor of [the official migrations plugin](https://github.com/cakephp/migrations). + CakePHP Bootstrap ----------------- @@ -43,12 +54,12 @@ Depending on the storage backend of your choice, for example Amazon S3 or Dropbo Please see the [Specific Adapter Configuration](Specific-Adapter-Configurations.md) page of the documentation for more information about then. It is also worth checking the Gaufrette documentation for additonal adapters. Running Tests -============= +------------- The plugin tests are set up in a way that you can run them without putting the plugin into a CakePHP3 application. All you need to do is to go into the FileStorage folder and run these commands: ``` cd -composer install +composer update phpunit ``` diff --git a/docs/Documentation/Legacy-Event-Listeners.md b/docs/Documentation/Legacy-Event-Listeners.md new file mode 100644 index 00000000..dae72531 --- /dev/null +++ b/docs/Documentation/Legacy-Event-Listeners.md @@ -0,0 +1,50 @@ +Included Event Listeners +======================== + +**THESE LISTENERS ARE DEPRECATED!** + +Please use the new listeners from the `\Burzum\FileStorage\Storage\Listener` namespace! + +LocalFileStorageListener +------------------------ + +The file and folder structure it will generate looks like that: + +``` +basePath/files/xx/xx/xx//. +``` + +ImageProcessingListener +----------------------- + +This listener will create versions of images if Configure::read('Media.imageSizes.' . $model); is not empty. If no processing operations for that model were specified it will just save the image. + +This adapter replaces LocalImageProcessingListener and currently supports the Local and AmazonS3 adapter. + +The file and folder structure it will generate looks like that: + +``` +basePath/images/xx/xx/xx//. +``` + +Versioned images will look like that + +``` +basePath/images/xx/xx/xx//.. +``` + + * For the Local adapter basePath is the value configured for this adapter, by default the `TMP` constant. + * For AmazonS3 the basePath will be the bucket and Amazon S3 URL prefix. + +xx stands for a semi random alphanumerical value calculated based on the given file name if the Local adapter was used. + +**Some important notes about the path the processor generates:** + +The path stored to the database is **not** going to be the complete path, it won't add the filename for a reason. + +The filename is generated by the processor on the fly when adding/deleting/modifying images because the versions are build on the fly and not stored to the database. See `ImageProcessingListener::_buildPath()`. + +LocalImageProcessingListener (deprecated) +----------------------------------------- + +The LocalImageProcessingListener is **deprecated**, use ImageProcessingListener. diff --git a/docs/Documentation/Migrating-from-CakePHP-2.md b/docs/Documentation/Migrating-from-CakePHP-2.md index 1ade14ca..9a5486e6 100644 --- a/docs/Documentation/Migrating-from-CakePHP-2.md +++ b/docs/Documentation/Migrating-from-CakePHP-2.md @@ -1,14 +1,16 @@ Migrating from CakePHP 2 ======================== +Here is a list of things that have changed: + * The plugin doesn't any longer use the configuration namespace `Media` but instead uses now the more appropriate namespace `FileStorage`. * The plugin is not using the CakeDC Migrations plugin any more but [the official CakePHP Migrations plugin](https://github.com/cakephp/migrations). -* `Lib\Utility\FileStorageUtils` has been moved to `Lib\FileStorageUtils`. +* `Lib\Utility\StorageUtils` has been moved to `Storage\StorageUtils`. * `FileStorageTable::fileExtension()` has been removed, use `pathinfo($path, PATHINFO_EXTENSION)` instead. * `FileStorageTable::stripUuid()` has been removed, use events to handle the file saving and `AbstractStorageEventListener::stripDashes()`. * `FileStorageTable::tmpFile()` has been removed, use events to handle the file saving and `AbstractStorageEventListener::createTmpFile()`. * `FileStorageTable::tmpFile()` has been moved to `AbstractStorageEventListener::fsPath()`, use events to handle the file saving. -* `ImageStorageTable::hashOperations()` has been removed, use `FileStorageUtils::hashOperations()`. -* `ImageStorageTable::generateHashes()` has been removed, use `FileStorageUtils::generateHashes()`. -* `ImageStorageTable::ksortRecursive()` has been removed, use `FileStorageUtils::ksortRecursive()`. -* Former `UploadValidatorBehavior::uploadArray()` has been moved to `FileStorageUtils::uploadArray()`. +* `ImageStorageTable::hashOperations()` has been removed, use `StorageUtils::hashOperations()`. +* `ImageStorageTable::generateHashes()` has been removed, use `StorageUtils::generateHashes()`. +* `ImageStorageTable::ksortRecursive()` has been removed, use `StorageUtils::ksortRecursive()`. +* Former `UploadValidatorBehavior::uploadArray()` has been moved to `StorageUtils::uploadArray()`. diff --git a/docs/Documentation/Path-Builders.md b/docs/Documentation/Path-Builders.md new file mode 100644 index 00000000..f2d0a46b --- /dev/null +++ b/docs/Documentation/Path-Builders.md @@ -0,0 +1,40 @@ +Path Builders +============= + +Path builders are classes that are used to build the storage paths for a file based on the information coming from the `file_storage` table. + +A path builder *should but doesn't have to* build a unique path per entity based on all the data available in the entity. + +They implement at least these methods: + + * filename + * path + * fullPath + * url + +Each of them will take a `FileStorage` entity as first argument. Based on that entity it will generate a path depending on the logic implemented in the path builder. + +The reason for this is to separate or share, just as needed, the path building logic between different storage systems. For example S3 differs in it's first part of the path, it's using a bucket while locally you usually have something like a base path instead of the bucket. + +If you want to change the way your files are saved extend the `BasePathBuilder` class. + +BasePathBuilder +--------------- + +This is the path builder all other BP's should inherit from. But if you like to write your very own BP you're free to implement it from the ground up but you'll have to use the PathBuilderInterface. + +The BasePathBuilder comes with a set of configuration options: + +```php +[ + 'stripUuid' => true, + 'pathPrefix' => '', + 'pathSuffix' => '', + 'filePrefix' => '', + 'fileSuffix' => '', + 'preserveFilename' => false, + 'preserveExtension' => true, + 'uuidFolder' => true, + 'randomPath' => 'sha1' +] +``` \ No newline at end of file diff --git a/docs/Documentation/Requirements.md b/docs/Documentation/Requirements.md index 3b9836c5..1c707c45 100644 --- a/docs/Documentation/Requirements.md +++ b/docs/Documentation/Requirements.md @@ -2,9 +2,8 @@ Requirements ============ * CakePHP 3.0+ - * PHP 5.4.19+ * Gaufrette Library (included as composer dependency) Optional but required for image processing: - * The Imagine Image processing plugin https://github.com/burzum/imagine if you want to process and storage images. + * [The Imagine Image processing plugin](https://github.com/burzum/cakephp-imagine-plugin) if you want to process and store images. diff --git a/docs/Documentation/Specific-Adapter-Configurations.md b/docs/Documentation/Specific-Adapter-Configurations.md index baa503ab..1dfa32d6 100644 --- a/docs/Documentation/Specific-Adapter-Configurations.md +++ b/docs/Documentation/Specific-Adapter-Configurations.md @@ -1,10 +1,14 @@ -# Specific Addapter Configuration +Specific Addapter Configuration +=============================== Gaufrette does not come with a lot detail about what exactly some adapters expect so here is a list to help you with that. But you should not blindly copy and paste that code, get an understanding of the storage service you want to use before! -## Local Filesystem +Keep in mind that the instructions here might be outdated as external APIs and SDKs can and probably will change at some time! If this happens please create an issue ticket on Github and include the way to configure the adapter. + +Local Filesystem +---------------- By default the StorageManager already comes with a pre-configured adapter instance for the local file system adapter. @@ -36,7 +40,8 @@ Symlink Windows Example: mklink /D "C:\webstack\htdocs\my-app\webroot\img\uploads" "C:\webstack\htdocs\my-app\file_storage" ``` -## AmazonS3 - AwsS3 Adapter +AmazonS3 - AwsS3 Adapter +------------------------ Get the SDK from here https://github.com/aws/aws-sdk-php or get it via composer ```aws/aws-sdk-php```. If you're not using composer you'll have to add it to your own autoloader or load it manually. @@ -60,7 +65,8 @@ StorageManager::config('S3Image', array( ); ``` -## AmazonS3 - AmazonS3 Adapter (legacy) +AmazonS3 - AmazonS3 Adapter (legacy) +------------------------------------ *This adapter is legacy code, you should use the AwsS3 adapter instead!* @@ -89,7 +95,8 @@ StorageManager::config('S3', array( ); ``` -## OpenCloud (Rackspace) +OpenCloud (Rackspace) +--------------------- Get the SDK from here http://github.com/rackspace/php-opencloud and add it to your class autoloader diff --git a/docs/Documentation/The-Storage-Manager.md b/docs/Documentation/The-Storage-Manager.md index 4227f5e9..4a0eeaa6 100644 --- a/docs/Documentation/The-Storage-Manager.md +++ b/docs/Documentation/The-Storage-Manager.md @@ -1,7 +1,7 @@ The Storage Manager =================== -The [Storage Manager](Lib/StorageManager.php) class is a singleton class that manages a collection of storage adapter instances. +The [Storage Manager](../../src/Storage/StorageManager.php) class is a singleton class that manages a collection of storage adapter instances. To configure adapters use the ```StorageManager::config()``` method. First argument is the name of the config, second an array of options for that adapter. The options array keys can be different for each adapter, depending on the storage system it connects to. @@ -39,4 +39,9 @@ If you want to flush *all* adapter configs and instances simply call it without StorageManager::flush(); ``` -There will be no adapter instance left after this, you must add a new config to use any adapter. \ No newline at end of file +There will be no adapter instance left after this, you must add a new config to use any adapter. + +Adapter Configuration +--------------------- + +Some adapters require a more or less complex configuration and setup depending on their API and provided SDK that the adapter class is using. Please see the [specific adapter configuration](Specific-Adapter-Configurations.md) section of the documentation for some of them. diff --git a/docs/Home.md b/docs/Home.md index 12d0f7dd..8fba809d 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -12,13 +12,15 @@ Documentation * [Requirements](Documentation/Requirements.md) * [Installation](Documentation/Installation.md) -* [Database Setup](Documentation/Database-Setup.md) +* [How it works](Documentation/How-it-works.md) * [The Storage Manager](Documentation/The-Storage-Manager.md) * [How to Use it](Documentation/How-To-Use.md) * [Image Storage and Versioning](Documentation/Image-Storage-And-Versioning.md) * [The Image Helper](Documentation/The-Image-Helper.md) * [Specific Adapter Configurations](Documentation/Specific-Adapter-Configurations.md) * [Included Event Listeners](Documentation/Included-Event-Listeners.md) +* [Legacy Event Listeners](Documentation/Legacy-Event-Listeners.md) +* [Path Builders](Documentation/Path-Builders.md) Tutorials --------- diff --git a/docs/Tutorials/Quick-Start.md b/docs/Tutorials/Quick-Start.md index 225af050..672d1d2a 100644 --- a/docs/Tutorials/Quick-Start.md +++ b/docs/Tutorials/Quick-Start.md @@ -42,7 +42,9 @@ Configure::write('FileStorage', array( 'thumbnail' => array( 'mode' => 'inbound', 'width' => 800, - 'height' => 800)), + 'height' => 800 + ) + ), 'medium' => array( 'thumbnail' => array( 'mode' => 'inbound', @@ -192,7 +194,7 @@ class ProductsController extends AppController { Products Upload View -------------------- -View for the controller action above. +View for the controller action above `Products/upload.ctp`: ```php echo $this->Form->create($productImage, array( diff --git a/docs/Tutorials/Replacing-Files.md b/docs/Tutorials/Replacing-Files.md index 98ae2d20..02873a1d 100644 --- a/docs/Tutorials/Replacing-Files.md +++ b/docs/Tutorials/Replacing-Files.md @@ -22,4 +22,4 @@ The the trick here is the `old_file_id`. The `FileStorageTable` table, which `Im So all you have to do to replace an image is to pass the `old_file_id` along with your new file data. -Just make sure that nobody can tamper your forms with unwanted data! If somebody can do that they can pass any id to delete *any* file! It is recommended to use the [Security component](http://book.cakephp.org/3.0/en/core-libraries/components/security-component.html) of the framework to avoid that. +**Just make sure that nobody can tamper your forms with unwanted data!** If somebody can do that they can pass any id to delete *any* file! It is recommended to use the [Security component](http://book.cakephp.org/3.0/en/core-libraries/components/security-component.html) of the framework to avoid that. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0f6c6aa9..3f8a6e44 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -29,12 +29,18 @@ - - - - + + + + ./src + + ./src/Storage/PathBuilder/PathBuilderInterface.php + ./vendor + ./tests + ./src/Model/Entity + ./src/Event + ./src/Lib + + + diff --git a/src/Event/AbstractStorageEventListener.php b/src/Event/AbstractStorageEventListener.php index 87fd28c1..b8d51469 100644 --- a/src/Event/AbstractStorageEventListener.php +++ b/src/Event/AbstractStorageEventListener.php @@ -1,16 +1,16 @@ config($config); @@ -120,7 +120,7 @@ public function buildFilename($table, $entity) { return $entity['filename']; } $filename = $entity['id']; - if ($this->_config['stripUuid'] === true) { + if ($this->_config['stripUuid'] === true) { $filename = $this->stripDashes($filename); } if ($this->_config['preserveExtension'] === true) { @@ -133,7 +133,7 @@ public function buildFilename($table, $entity) { * Builds the path under which the data gets stored in the storage adapter. * * @param Table $table - * @param Entity $entity + * @param EntityInterface $entity * @return string */ public function buildPath($table, $entity) { @@ -141,10 +141,10 @@ public function buildPath($table, $entity) { if ($this->_config['tableFolder']) { $path .= $table->table() . DS; } - if ($this->_config['randomPath'] == true) { - $path .= FileStorageUtils::randomPath($entity[$table->primaryKey()]); + if ($this->_config['randomPath'] === true) { + $path .= StorageUtils::randomPath($entity[$table->primaryKey()]); } - if ($this->_config['uuidFolder'] == true) { + if ($this->_config['uuidFolder'] === true) { $path .= $this->stripDashes($entity[$table->primaryKey()]) . DS; } return $path; @@ -164,7 +164,7 @@ protected function _checkEvent(Event $event) { } return ( $this->_checkTable($event) - && $this->getAdapterClassName($event->data['record']['adapter']) + && (bool)$this->getAdapterClassName($event->data['record']['adapter']) && $this->_modelFilter($event) ); } @@ -216,7 +216,7 @@ protected function _getAdapterClassFromConfig($configName) { * You must define a list of supported classes via AbstractStorageEventListener::$_adapterClasses. * * @param string $configName Name of the adapter configuration. - * @return boolean|string String, the adapter class name or false if it was not found. + * @return string|false String, the adapter class name or false if it was not found. */ public function getAdapterClassName($configName) { $className = $this->_getAdapterClassFromConfig($configName); @@ -268,17 +268,12 @@ public function getAdapter($configName) { * @param string $path Path / key of the storage adapter file * @param string $tmpFolder * @throws Exception - * @return bool|string + * @return string */ protected function _tmpFile($Storage, $path, $tmpFolder = null) { - try { - $tmpFile = $this->createTmpFile($tmpFolder); - file_put_contents($tmpFile, $Storage->read($path)); - return $tmpFile; - } catch (Exception $e) { - $this->log($e->getMessage(), 'file_storage'); - throw $e; - } + $tmpFile = $this->createTmpFile($tmpFolder); + file_put_contents($tmpFile, $Storage->read($path)); + return $tmpFile; } /** @@ -314,7 +309,7 @@ public function createTmpFile($folder = null, $checkAndCreatePath = true) { */ public function fsPath($type, $string, $idFolder = true) { $string = str_replace('-', '', $string); - $path = $type . DS . FileStorageUtils::randomPath($string); + $path = $type . DS . StorageUtils::randomPath($string); if ($idFolder) { $path .= $string . DS; } diff --git a/src/Event/ImageProcessingListener.php b/src/Event/ImageProcessingListener.php index d16d396d..6ff86b41 100644 --- a/src/Event/ImageProcessingListener.php +++ b/src/Event/ImageProcessingListener.php @@ -6,8 +6,8 @@ use Cake\Event\Event; use Cake\Core\Configure; use Cake\ORM\Table; -use Burzum\FileStorage\Lib\StorageManager; -use Burzum\FileStorage\Lib\FileStorageUtils; +use Burzum\FileStorage\Storage\StorageManager; +use Burzum\FileStorage\Storage\StorageUtils; /** * @author Florian Krämer @@ -79,6 +79,7 @@ public function implementedEvents() { */ protected function _autoRotate($imageFile, $format) { $orientation = ImagineUtility::getImageOrientation($imageFile); + $degree = 0; if ($orientation === false) { return false; } @@ -111,7 +112,7 @@ protected function _autoRotate($imageFile, $format) { * @param array $operations * @throws \Burzum\FileStorage\Event\Exception * @throws \Exception - * @return boolean + * @return false|null */ protected function _createVersions(Table $table, $entity, array $operations) { $Storage = StorageManager::adapter($entity['adapter']); @@ -119,10 +120,10 @@ protected function _createVersions(Table $table, $entity, array $operations) { $tmpFile = $this->_tmpFile($Storage, $path, TMP . 'image-processing'); foreach ($operations as $version => $imageOperations) { - $hash = FileStorageUtils::hashOperations($imageOperations); + $hash = StorageUtils::hashOperations($imageOperations); $string = $this->_buildPath($entity, true, $hash); - if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3' ) { + if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3') { $string = str_replace('\\', '/', $string); } @@ -132,9 +133,9 @@ protected function _createVersions(Table $table, $entity, array $operations) { try { $image = $table->processImage($tmpFile, null, array('format' => $entity['extension']), $imageOperations); - $result = $Storage->write($string, $image->get($entity['extension']), true); + $Storage->write($string, $image->get($entity['extension']), true); } catch (\Exception $e) { - $this->log($e->getMessage(), 'file_storage'); + $this->log($e->getMessage()); unlink($tmpFile); throw $e; } @@ -178,9 +179,9 @@ protected function _removeVersions(Event $Event) { $Storage = $Event->data['storage']; $record = $Event->data['record']; foreach ($Event->data['operations'] as $version => $operations) { - $hash = FileStorageUtils::hashOperations($operations); + $hash = StorageUtils::hashOperations($operations); $string = $this->_buildPath($record, true, $hash); - if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3' ) { + if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3') { $string = str_replace('\\', '/', $string); } try { @@ -188,7 +189,7 @@ protected function _removeVersions(Event $Event) { $Storage->delete($string); } } catch (\Exception $e) { - $this->log($e->getMessage(), 'file_storage'); + $this->log($e->getMessage()); } } $Event->stopPropagation(); @@ -199,13 +200,13 @@ protected function _removeVersions(Event $Event) { * afterDelete * * @param Event $Event - * @return void + * @return boolean|null */ public function afterDelete(Event $Event) { if ($this->_checkEvent($Event)) { $record = $Event->data['record']; $string = $this->_buildPath($record, true, null); - if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3' ) { + if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3') { $string = str_replace('\\', '/', $string); } try { @@ -214,8 +215,8 @@ public function afterDelete(Event $Event) { return false; } $Storage->delete($string); - } catch (Exception $e) { - $this->log($e->getMessage(), 'file_storage'); + } catch (\Exception $e) { + $this->log($e->getMessage()); return false; } $operations = Configure::read('FileStorage.imageSizes.' . $record['model']); @@ -237,7 +238,7 @@ public function beforeSave(Event $Event) { if ($this->_checkEvent($Event)) { if (in_array($Event->data['record']['model'], (array)$this->config('autoRotate'))) { $imageFile = $Event->data['record']['file']['tmp_name']; - $format = FileStorageUtils::fileExtension($Event->data['record']['file']['name']); + $format = StorageUtils::fileExtension($Event->data['record']['file']['name']); $this->_autoRotate($imageFile, $format); } } @@ -265,7 +266,7 @@ public function afterSave(Event $Event) { $path = $record['path'] . $filename . '.' . $record['extension']; } - if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3' ) { + if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3') { $path = str_replace('\\', '/', $path); $record['path'] = str_replace('\\', '/', $record['path']); } @@ -281,10 +282,9 @@ public function afterSave(Event $Event) { if (!empty($operations)) { $this->_createVersions($table, $record, $operations); } - $table->data = $data; } catch (\Exception $e) { - $this->log($e->getMessage(), 'file_storage'); + $this->log($e->getMessage()); } } } @@ -322,7 +322,7 @@ public function imagePath(Event $Event) { protected function _buildLocalPath(Event $Event) { extract($Event->data); $path = $this->_buildPath($image, true, $hash); - $Event->data['path'] = '/' . $path; + $Event->data['path'] = $Event->result = '/' . $path; $Event->stopPropagation(); } @@ -367,7 +367,7 @@ protected function _buildAmazonS3Path(Event $Event) { $image['path'] = str_replace('\\', '/', $image['path']); $bucketPrefix = !empty($Event->data['options']['bucketPrefix']) && $Event->data['options']['bucketPrefix'] === true; - $Event->data['path'] = $this->_buildCloudFrontDistributionUrl($http, $image['path'], $bucket, $bucketPrefix, $cfDist); + $Event->data['path'] = $Event->result = $this->_buildCloudFrontDistributionUrl($http, $image['path'], $bucket, $bucketPrefix, $cfDist); $Event->stopPropagation(); } @@ -379,11 +379,12 @@ protected function _buildAmazonS3Path(Event $Event) { * @param string $bucket * @param string null $bucketPrefix * @param string $cfDist + * @param boolean $bucketPrefix * @return string */ protected function _buildCloudFrontDistributionUrl($protocol, $image, $bucket, $bucketPrefix = null, $cfDist = null) { $path = $protocol . '://'; - if ($cfDist) { + if (is_string($cfDist)) { $path .= $cfDist; } else { if ($bucketPrefix) { @@ -417,12 +418,12 @@ protected function _buildPath($record, $extension = true, $hash = null) { if (!empty($hash)) { $path .= '.' . $hash; } - if ($extension == true) { + if ($extension === true) { $path .= '.' . $record['extension']; } } - if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3' ) { + if ($this->adapterClass === 'AmazonS3' || $this->adapterClass === 'AwsS3') { return str_replace('\\', '/', $path); } @@ -433,7 +434,7 @@ protected function _buildPath($record, $extension = true, $hash = null) { * Gets the adapter class name from the adapter configuration key * * @param string - * @return void + * @return string|false */ public function getAdapterClassName($adapterConfigName) { $config = StorageManager::config($adapterConfigName); diff --git a/src/Event/LocalFileStorageListener.php b/src/Event/LocalFileStorageListener.php index a8546165..c86269db 100644 --- a/src/Event/LocalFileStorageListener.php +++ b/src/Event/LocalFileStorageListener.php @@ -1,10 +1,9 @@ _checkEvent($event)) { @@ -94,14 +93,15 @@ public function afterSave(Event $event) { $Storage = StorageManager::adapter($entity['adapter']); try { $filename = $this->buildFileName($table, $entity); - $entity['path'] = $this->buildPath($table, $entity); + $entity->path = $this->buildPath($table, $entity); + $Storage->write($entity['path'] . $filename, file_get_contents($entity['file']['tmp_name']), true); $table->save($entity, array( 'validate' => false, 'callbacks' => false )); - } catch (Exception $e) { - $this->log($e->getMessage(), 'file_storage'); + } catch (\Exception $e) { + $this->log($e->getMessage()); } } } diff --git a/src/Event/S3StorageListener.php b/src/Event/S3StorageListener.php index 3fa4f14a..8605997d 100644 --- a/src/Event/S3StorageListener.php +++ b/src/Event/S3StorageListener.php @@ -2,8 +2,6 @@ namespace Burzum\FileStorage\Event; use Cake\Event\Event; -use Cake\ORM\Table; -use Cake\ORM\Entity; /** * S3StorageListener @@ -40,7 +38,7 @@ public function implementedEvents() { * afterDelete * * @param \Cake\Event\Event $Event - * @return void + * @return boolean|null */ public function afterDelete(Event $Event) { if ($this->_checkEvent($Event)) { @@ -54,7 +52,7 @@ public function afterDelete(Event $Event) { } $Storage->delete($path['combined']); } catch (\Exception $e) { - $this->log($e->getMessage(), 'file_storage'); + $this->log($e->getMessage()); return false; } return true; @@ -76,13 +74,13 @@ public function afterSave(Event $Event) { try { $path = $this->buildPath($Event->subject(), $Event->data['record']); $record['path'] = $path['path']; - $result = $Storage->write($path['combined'], file_get_contents($record['file']['tmp_name']), true); + $Storage->write($path['combined'], file_get_contents($record['file']['tmp_name']), true); $table->save($record, array( 'validate' => false, 'callbacks' => false) ); - } catch (Exception $e) { - $this->log($e->getMessage(), 'file_storage'); + } catch (\Exception $e) { + $this->log($e->getMessage()); } } } @@ -91,7 +89,7 @@ public function afterSave(Event $Event) { * Builds the storage path for this adapter. * * @param \Cake\ORM\Table $table - * @param \Cake\ORM\Entity $entity + * @param \Cake\Datasource\EntityInterface $entity * @return array */ public function buildPath($table, $entity) { diff --git a/src/Lib/FileStorageUtils.php b/src/Lib/FileStorageUtils.php index f7aecf47..1ff5b2f2 100644 --- a/src/Lib/FileStorageUtils.php +++ b/src/Lib/FileStorageUtils.php @@ -1,15 +1,18 @@ $fieldvalue) { - foreach ($fieldvalue as $paramname => $paramvalue) { - foreach ((array)$paramvalue as $index => $value) { - $newfiles[$fieldname][$index][$paramname] = $value; - } - } - } - } - return $newfiles; + return StorageUtils::normalizeGlobalFilesArray($array); } /** * Serializes and then hashes an array of operations that are applied to an image * * @param array $operations - * @return array + * @return string */ public static function hashOperations($operations) { - self::ksortRecursive($operations); - return substr(md5(serialize($operations)), 0, 8); + return StorageUtils::hashOperations($operations); } /** - * Generate hashes + * Generates the hashes for the different image version configurations. * - * @param string - * @return void + * @param string|array $configPath + * @return array */ public static function generateHashes($configPath = 'FileStorage') { - $imageSizes = Configure::read($configPath . '.imageSizes'); - if (is_null($imageSizes)) { - throw new \RuntimeException(sprintf('Image processing configuration in %s is missing!', $configPath . '.imageSizes')); - } - self::ksortRecursive($imageSizes); - foreach ($imageSizes as $model => $version) { - foreach ($version as $name => $operations) { - Configure::write($configPath . '.imageHashes.' . $model . '.' . $name, self::hashOperations($operations)); - } - } + return StorageUtils::generateHashes($configPath); } /** @@ -143,18 +101,11 @@ public static function generateHashes($configPath = 'FileStorage') { * * @param array $array * @param integer - * @return void + * @return string * @link https://gist.github.com/601849 */ - public static function ksortRecursive(&$array, $sortFlags = SORT_REGULAR) { - if (!is_array($array)) { - return false; - } - ksort($array, $sortFlags); - foreach ($array as &$arr) { - self::ksortRecursive($arr, $sortFlags); - } - return true; + public static function ksortRecursive(&$array, $sortFlags) { + return StorageUtils::getFileHash($array, $sortFlags); } /** @@ -165,16 +116,23 @@ public static function ksortRecursive(&$array, $sortFlags = SORT_REGULAR) { * @return array Array that matches the structure of a regular upload */ public static function uploadArray($file, $filename = null) { - $File = new File($file); - if (empty($fileName)) { - $filename = basename($file); - } - return [ - 'name' => $filename, - 'tmp_name' => $file, - 'error' => 0, - 'type' => $File->mime(), - 'size' => $File->size() - ]; + return StorageUtils::uploadArray($file, $filename); + } + +/** + * Gets the hash of a file. + * + * You can use this to compare if you got two times the same file uploaded. + * + * @param string $file Path to the file on your local machine. + * @param string $method 'md5' or 'sha1' + * @throws \InvalidArgumentException + * @link http://php.net/manual/en/function.md5-file.php + * @link http://php.net/manual/en/function.sha1-file.php + * @link http://php.net/manual/en/function.sha1-file.php#104748 + * @return string + */ + public static function getFileHash($file, $method = 'sha1') { + return StorageUtils::getFileHash($file, $method); } } diff --git a/src/Lib/StorageManager.php b/src/Lib/StorageManager.php index 253b3a14..23294db7 100644 --- a/src/Lib/StorageManager.php +++ b/src/Lib/StorageManager.php @@ -1,40 +1,18 @@ [ - 'adapterOptions' => [TMP, true], - 'adapterClass' => '\Gaufrette\Adapter\Local', - 'class' => '\Gaufrette\Filesystem' - ] - ]; - -/** - * Return a singleton instance of the StorageManager. - * - * @return ClassRegistry instance - */ - public static function &getInstance() { - static $instance = array(); - if (!$instance) { - $instance[0] = new StorageManager(); - } - return $instance[0]; - } +class StorageManager { /** * Gets the configuration array for an adapter. @@ -44,17 +22,7 @@ public static function &getInstance() { * @return mixed */ public static function config($adapter, $options = array()) { - $_this = StorageManager::getInstance(); - - if (!empty($adapter) && !empty($options)) { - return $_this->_adapterConfig[$adapter] = $options; - } - - if (isset($_this->_adapterConfig[$adapter])) { - return $_this->_adapterConfig[$adapter]; - } - - return false; + return NewStorageManager::config($adapter, $options); } /** @@ -65,14 +33,7 @@ public static function config($adapter, $options = array()) { * @return boolean True on success */ public static function flush($name = null) { - $_this = StorageManager::getInstance(); - - if (isset($_this->_adapterConfig[$name])) { - unset($_this->_adapterConfig[$name]); - return true; - } - - return false; + return NewStorageManager::flush($name); } /** @@ -84,37 +45,6 @@ public static function flush($name = null) { * @return Gaufrette object as configured by first argument */ public static function adapter($adapterName, $renewObject = false) { - $_this = StorageManager::getInstance(); - - $isConfigured = true; - if (is_string($adapterName)) { - if (!empty($_this->_adapterConfig[$adapterName])) { - $adapter = $_this->_adapterConfig[$adapterName]; - } else { - throw new \RuntimeException(sprintf('Invalid Storage Adapter %s!', $adapterName)); - } - - if (!empty($_this->_adapterConfig[$adapterName]['object']) && $renewObject === false) { - return $_this->_adapterConfig[$adapterName]['object']; - } - } - - if (is_array($adapterName)) { - $adapter = $adapterName; - $isConfigured = false; - } - - $class = $adapter['adapterClass']; - $Reflection = new \ReflectionClass($class); - if (!is_array($adapter['adapterOptions'])) { - throw new \InvalidArgumentException(sprintf('%s: The adapter options must be an array!', $adapterName)); - } - $adapterObject = $Reflection->newInstanceArgs($adapter['adapterOptions']); - $engineObject = new $adapter['class']($adapterObject); - if ($isConfigured) { - $_this->_adapterConfig[$adapterName]['object'] = &$engineObject; - } - return $engineObject; + return NewStorageManager::adapter($adapterName, $renewObject); } - -} \ No newline at end of file +} diff --git a/src/Model/Behavior/Event/EventDispatcherTrait.php b/src/Model/Behavior/Event/EventDispatcherTrait.php new file mode 100644 index 00000000..05ea7535 --- /dev/null +++ b/src/Model/Behavior/Event/EventDispatcherTrait.php @@ -0,0 +1,43 @@ +_table; + if ($subject === null) { + $subject = $this->_table; + } + return $this->_dispatchEvent($name, $data, $subject); + } +} diff --git a/src/Model/Behavior/FileStorageBehavior.php b/src/Model/Behavior/FileStorageBehavior.php new file mode 100644 index 00000000..954fa7eb --- /dev/null +++ b/src/Model/Behavior/FileStorageBehavior.php @@ -0,0 +1,224 @@ + [ + 'getFileInfoFromUpload' => 'getFileInfoFromUpload', + 'deleteOldFileOnSave' => 'deleteOldFileOnSave', + 'fullFilePath' => 'fullFilePath', + 'fileUrl' => 'fileUrl' + ] + ]; + +/** + * + * @param array $config + * @return void + */ + public function initialize(array $config) { + $this->_table->record = []; + $this->_eventManager = $this->_table->eventManager(); + + parent::initialize($config); + } + +/** + * Gets information about the file that is being uploaded. + * + * - gets the file size + * - gets the mime type + * - gets the extension if present + * - sets the adapter by default to local if not already set + * - sets the model field to the table name if not already set + * + * @param array|\ArrayAccess $upload + * @param string $field + * @return void + */ + public function getFileInfoFromUpload(&$upload, $field = 'file') { + if (!empty($upload[$field]['tmp_name'])) { + $File = new File($upload[$field]['tmp_name']); + $upload['filesize'] = $File->size(); + $upload['mime_type'] = $File->mime(); + } + if (!empty($upload[$field]['name'])) { + $upload['extension'] = pathinfo($upload[$field]['name'], PATHINFO_EXTENSION); + $upload['filename'] = $upload[$field]['name']; + } + if (empty($upload['model'])) { + $upload['model'] = $this->_table->table(); + } + if (empty($upload['adapter'])) { + $upload['adapter'] = 'Local'; + } + } + +/** + * beforeSave callback + * + * @param \Cake\Event\Event $event + * @param \ArrayAccess $data + * @return void + */ + public function beforeMarshal(Event $event, ArrayAccess $data) { + $this->getFileInfoFromUpload($data); + } + +/** + * beforeSave callback + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return boolean true on success + */ + public function beforeSave(Event $event, EntityInterface $entity, $options) { + $storageEvent = $this->dispatchEvent('FileStorage.beforeSave', [ + 'record' => $entity, + 'storage' => $this->storageAdapter($entity->get('adapter')) + ]); + if ($storageEvent->isStopped()) { + return false; + } + return true; + } + +/** + * afterSave callback + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return boolean + */ + public function afterSave(Event $event, EntityInterface $entity, $options) { + $this->dispatchEvent('FileStorage.afterSave', [ + 'record' => $entity, + 'storage' => $this->storageAdapter($entity->get('adapter')), + 'created' => $entity->isNew() + ]); + $this->deleteOldFileOnSave($entity); + return true; + } + +/** + * Get a copy of the actual record before we delete it to have it present in afterDelete + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @return boolean + */ + public function beforeDelete(Event $event, EntityInterface $entity) { + $primaryKey = $this->_table->primaryKey(); + $this->_table->record = $this->_table->find() + ->contain([]) + ->where([ + $this->_table->aliasField($primaryKey) => $entity->get($primaryKey) + ]) + ->first(); + + if (empty($this->_table->record)) { + return false; + } + + return true; + } + +/** + * afterDelete callback + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return boolean + */ + public function afterDelete(Event $event, EntityInterface $entity, $options) { + $this->dispatchEvent('FileStorage.afterDelete', [ + 'record' => $entity, + 'storage' => $this->storageAdapter($entity->get('adapter')) + ]); + return true; + } + +/** + * Deletes an old file to replace it with the new one if an old id was passed. + * + * Thought to be called in Model::afterSave() but can be used from any other + * place as well like Model::beforeSave() as long as the field data is present. + * + * The old id has to be the UUID of the file_storage record that should be deleted. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param string $oldIdField Name of the field in the data that holds the old id. + * @return boolean Returns true if the old record was deleted + */ + public function deleteOldFileOnSave(EntityInterface $entity, $oldIdField = 'old_file_id') { + if ($entity->has($oldIdField) && $entity->has('model')) { + $oldEntity = $this->_table->find() + ->contain([]) + ->where([ + $this->_table->aliasField($this->_table->primaryKey()) => $entity->get($oldIdField), + 'model' => $entity->get('model') + ]) + ->first(); + if (!empty($oldEntity)) { + return $this->_table->delete($oldEntity); + } + } + return false; + } + +/** + * Returns full file path for an entity. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function fullFilePath(EntityInterface $entity, array $options = []) { + $pathBuilder = $this->createPathBuilder($entity['adapter']); + return $pathBuilder->fullPath($entity, $options); + } + +/** + * Returns file url for an entity. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function fileUrl(EntityInterface $entity, array $options = []) { + $pathBuilder = $this->createPathBuilder($entity['adapter']); + return $pathBuilder->url($entity, $options); + } +} diff --git a/src/Model/Behavior/ImageStorageBehavior.php b/src/Model/Behavior/ImageStorageBehavior.php new file mode 100644 index 00000000..c18f3f55 --- /dev/null +++ b/src/Model/Behavior/ImageStorageBehavior.php @@ -0,0 +1,226 @@ + [ + 'validateImageSize' => 'validateImageSize', + 'getImageVersions' => 'getImageVersions' + ] + ]; + +/** + * + * @param array $config + * @return void + */ + public function initialize(array $config) { + $this->_eventManager = $this->_table->eventManager(); + + parent::initialize($config); + + //remove FileStorageBehavior afterSave and afterDelete listeners to keep BC with overwrited callbacks in old tables. + $fileStorageBehavior = $this->_table->behaviors()->get('FileStorage'); + if ($fileStorageBehavior) { + $events = ['Model.afterSave', 'Model.afterDelete']; + foreach ($events as $event) { + $this->_eventManager->off($event, $fileStorageBehavior); + } + } + } + +/** + * beforeSave callback + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return boolean true on success + */ + public function beforeSave(Event $event, EntityInterface $entity, $options) { + $imageEvent = $this->dispatchEvent('ImageStorage.beforeSave', [ + 'record' => $entity + ]); + if ($imageEvent->isStopped()) { + return false; + } + return true; + } + +/** + * afterSave callback + * + * Does not call the parent to avoid that the regular file storage event listener saves the image already + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return boolean + */ + public function afterSave(Event $event, EntityInterface $entity, $options) { + if ($entity->isNew()) { + $this->dispatchEvent('ImageStorage.afterSave', [ + 'record' => $entity, + 'storage' => $this->storageAdapter($entity->get('adapter')) + ]); + $this->_table->deleteOldFileOnSave($entity); + } + return true; + } + +/** + * Get a copy of the actual record before we delete it to have it present in afterDelete + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @return boolean + */ + public function beforeDelete(Event $event, EntityInterface $entity) { + $imageEvent = $this->dispatchEvent('ImageStorage.beforeDelete', [ + 'record' => $this->_table->record, + 'storage' => $this->storageAdapter($this->_table->record['adapter']) + ]); + + if ($imageEvent->isStopped()) { + return false; + } + + return true; + } + +/** + * After the main file was deleted remove the the thumbnails + * + * Note that we do not call the parent::afterDelete(), we just want to trigger the ImageStorage.afterDelete event but not the FileStorage.afterDelete at the same time! + * + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return boolean + */ + public function afterDelete(Event $event, EntityInterface $entity, $options) { + $this->dispatchEvent('ImageStorage.afterDelete', [ + 'record' => $entity, + 'storage' => $this->storageAdapter($entity->get('adapter')) + ]); + return true; + } + +/** + * Image size validation method + * + * @param mixed $check + * @param array $options is an array with key width or height and a value of array + * with two options, operator and value. For example: + * array('height' => array('==', 100)) will only be true if the image has a + * height of exactly 100px. See the CakePHP core class and method + * Validation::comparison for all operators. + * @return boolean true + * @see Validation::comparison() + * @throws \InvalidArgumentException + */ + public function validateImageSize($check, array $options = []) { + if (!isset($options['height']) && !isset($options['width'])) { + throw new \InvalidArgumentException('Missing image size validation options! You must provide a hight and / or width.'); + } + + if (is_string($check)) { + $imageFile = $check; + } else { + $check = array_values($check); + $check = $check[0]; + if (is_array($check) && isset($check['tmp_name'])) { + $imageFile = $check['tmp_name']; + } else { + $imageFile = $check; + } + } + + $imageSizes = $this->_table->getImageSize($imageFile); + + if (isset($options['height'])) { + $height = Validation::comparison($imageSizes[1], $options['height'][0], $options['height'][1]); + } else { + $height = true; + } + + if (isset($options['width'])) { + $width = Validation::comparison($imageSizes[0], $options['width'][0], $options['width'][1]); + } else { + $width = true; + } + + if ($height === false || $width === false) { + return false; + } + + return true; + } + +/** + * Gets a list of image versions for a given record. + * + * Use this method to get a list of ALL versions when needed or to cache all the + * versions somewhere. This method will return all configured versions for an + * image. For example you could store them serialized along with the file data + * by adding a "versions" field to the DB table and extend this model. + * + * Just in case you're wondering about the event name in the method code: It's + * called FileStorage.ImageHelper.imagePath there because the event is the same + * as in the helper. No need to introduce yet another event, the existing event + * already fulfills the purpose. I might rename this event in the 3.0 version of + * the plugin to a more generic one. + * + * @param \Cake\Datasource\EntityInterface $entity An ImageStorage database record + * @param array $options Options for the version. + * @return array A list of versions for this image file. Key is the version, value is the path or URL to that image. + */ + public function getImageVersions(EntityInterface $entity, $options = []) { + $versions = []; + $versionData = (array)Configure::read('FileStorage.imageSizes.' . $entity->get('model')); + $versionData['original'] = isset($options['originalVersion']) ? $options['originalVersion'] : 'original'; + foreach ($versionData as $version => $data) { + $hash = Configure::read('FileStorage.imageHashes.' . $entity->get('model') . '.' . $version); + $event = $this->dispatchEvent('ImageVersion.getVersions', [ + 'hash' => $hash, + 'image' => $entity, + 'version' => $version, + 'options' => [] + ]); + if ($event->isStopped()) { + $versions[$version] = str_replace('\\', '/', $event->data['path']); + } + } + return $versions; + } +} diff --git a/src/Model/Behavior/UploadValidatorBehavior.php b/src/Model/Behavior/UploadValidatorBehavior.php index ed7c233e..ce948a8f 100644 --- a/src/Model/Behavior/UploadValidatorBehavior.php +++ b/src/Model/Behavior/UploadValidatorBehavior.php @@ -5,7 +5,6 @@ use Cake\Event\Event; use Cake\Event\EventManager; use Cake\ORM\Table; -use Cake\ORM\Entity; use Cake\ORM\Behavior; use Cake\Utility\File; use Cake\Utility\Number; diff --git a/src/Model/Entity/FileStorage.php b/src/Model/Entity/FileStorage.php index 73f06fed..bc4cf917 100644 --- a/src/Model/Entity/FileStorage.php +++ b/src/Model/Entity/FileStorage.php @@ -33,6 +33,7 @@ class FileStorage extends Entity { 'created' => true, 'modified' => true, 'file' => true, + 'old_file_id' => true ]; } diff --git a/src/Model/Entity/ImageStorage.php b/src/Model/Entity/ImageStorage.php new file mode 100644 index 00000000..7b91cb14 --- /dev/null +++ b/src/Model/Entity/ImageStorage.php @@ -0,0 +1,13 @@ +size(); - $data['mime_type'] = $File->mime(); - } - if (!empty($data['file']['name'])) { - $data['extension'] = pathinfo($data['file']['name'], PATHINFO_EXTENSION); - $data['filename'] = $data['file']['name']; - } - if (empty($data['model'])) { - $data['model'] = $this->table(); - } - if (empty($data['adapter'])) { - $data['adapter'] = 'Local'; - } + public function beforeMarshal(Event $event, ArrayAccess $data) { + $this->getFileInfoFromUpload($data); } /** * beforeSave callback * - * @param Event $event - * @param Entity $entity + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity * @param array $options - * @return bool true on success + * @return boolean true on success */ - public function beforeSave(Event $event, Entity $entity, $options) { - $Event = new Event('FileStorage.beforeSave', $this, array( + public function beforeSave(Event $event, EntityInterface $entity, $options) { + $Event = $this->dispatchEvent('FileStorage.beforeSave', array( 'record' => $entity, - 'storage' => $this->getStorageAdapter($event->data['entity']['adapter']) + 'storage' => $this->storageAdapter($entity['adapter']) )); - $this->getEventManager()->dispatch($Event); if ($Event->isStopped()) { return false; } return true; } +/** + * Gets information about the file that is being uploaded. + * + * - gets the file size + * - gets the mime type + * - gets the extension if present + * - sets the adapter by default to local if not already set + * - sets the model field to the table name if not already set + * + * @param array|\ArrayAccess $upload + * @param string $field + * @return void + */ + public function getFileInfoFromUpload(&$upload, $field = 'file') { + if (!empty($upload[$field]['tmp_name'])) { + $File = new File($upload[$field]['tmp_name']); + $upload['filesize'] = $File->size(); + $upload['mime_type'] = $File->mime(); + } + if (!empty($upload[$field]['name'])) { + $upload['extension'] = pathinfo($upload[$field]['name'], PATHINFO_EXTENSION); + $upload['filename'] = $upload[$field]['name']; + } + if (empty($upload['model'])) { + $upload['model'] = $this->table(); + } + if (empty($upload['adapter'])) { + $upload['adapter'] = 'Local'; + } + } + /** * afterSave callback * - * @param Event $event - * @param Entity $entity + * @param \Cake\Event\Event $event + * @param \Cake\Datasource\EntityInterface $entity * @param array $options - * @return void + * @return boolean */ - public function afterSave(Event $event, Entity $entity, $options) { - $Event = new Event('FileStorage.afterSave', $this, [ - 'created' => $event->data['entity']->isNew(), + public function afterSave(Event $event, EntityInterface $entity, $options) { + $this->dispatchEvent('FileStorage.afterSave', [ 'record' => $entity, - 'storage' => $this->getStorageAdapter($event->data['entity']['adapter']) + 'created' => $event->data['entity']->isNew(), + 'storage' => $this->storageAdapter($entity['adapter']) ]); - $this->getEventManager()->dispatch($Event); $this->deleteOldFileOnSave($entity); return true; } @@ -132,11 +151,10 @@ public function afterSave(Event $event, Entity $entity, $options) { * Get a copy of the actual record before we delete it to have it present in afterDelete * * @param \Cake\Event\Event $event - * @param \Burzum\FileStorage\Model\Table\Entity $entity - * @param array $options + * @param \Cake\Datasource\EntityInterface $entity * @return boolean */ - public function beforeDelete(\Cake\Event\Event $event, \Cake\ORM\Entity $entity) { + public function beforeDelete(Event $event, EntityInterface $entity) { $this->record = $this->find() ->contain([]) ->where([ @@ -155,38 +173,18 @@ public function beforeDelete(\Cake\Event\Event $event, \Cake\ORM\Entity $entity) * afterDelete callback * * @param \Cake\Event\Event $event - * @param \Cake\ORM\Entity $entity + * @param \Cake\Datasource\EntityInterface $entity * @param array $options * @return boolean */ - public function afterDelete(\Cake\Event\Event $event, Entity $entity, $options) { - try { - $Storage = $this->getStorageAdapter($entity['adapter']); - $Storage->delete($entity['path']); - } catch (Exception $e) { - $this->log($e->getMessage(), 'file_storage'); - return false; - } - - $Event = new Event('FileStorage.afterDelete', $this, array( - 'record' => $event->data['record'], - 'storage' => $this->getStorageAdapter($entity['adapter']))); - $this->getEventManager()->dispatch($Event); - + public function afterDelete(Event $event, EntityInterface $entity, $options) { + $this->dispatchEvent('FileStorage.afterDelete', [ + 'record' => $entity, + 'storage' => $this->storageAdapter($entity['adapter']) + ]); return true; } -/** - * Get a storage adapter from the StorageManager - * - * @param string $adapterName - * @param boolean $renewObject - * @return \Gaufrette\Adapter - */ - public function getStorageAdapter($adapterName, $renewObject = false) { - return StorageManager::adapter($adapterName, $renewObject); - } - /** * Deletes an old file to replace it with the new one if an old id was passed. * @@ -195,17 +193,18 @@ public function getStorageAdapter($adapterName, $renewObject = false) { * * The old id has to be the UUID of the file_storage record that should be deleted. * - * @param string $oldIdField Name of the field in the data that holds the old id + * @param \Cake\Datasource\EntityInterface $entity + * @param string $oldIdField Name of the field in the data that holds the old id. * @return boolean Returns true if the old record was deleted */ - public function deleteOldFileOnSave(Entity $entity, $oldIdField = 'old_file_id') { + public function deleteOldFileOnSave(EntityInterface $entity, $oldIdField = 'old_file_id') { if (!empty($entity[$oldIdField]) && $entity['model']) { $oldEntity = $this->find() ->contain([]) - ->where( - [$this->alias() . '.' . $this->primaryKey() => $entity[$oldIdField], 'model' => $entity['model']]) + ->where([ + $this->alias() . '.' . $this->primaryKey() => $entity[$oldIdField], 'model' => $entity['model'] + ]) ->first(); - if (!empty($oldEntity)) { return $this->delete($oldEntity); } @@ -214,12 +213,34 @@ public function deleteOldFileOnSave(Entity $entity, $oldIdField = 'old_file_id') } /** - * Returns an EventManager instance + * Returns full file path for an entity. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function fullFilePath(EntityInterface $entity, array $options = []) { + $pathBuilder = $this->createPathBuilder($entity['adapter']); + return $pathBuilder->fullPath($entity, $options); + } + +/** + * Returns file url for an entity. * - * @return \Cake\Event\EventManager + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string */ - public function getEventManager() { - return EventManager::instance(); + public function fileUrl(EntityInterface $entity, array $options = []) { + $pathBuilder = $this->createPathBuilder($entity['adapter']); + return $pathBuilder->url($entity, $options); } +/** + * {@inheritDoc} + */ + public function dispatchEvent($name, $data = null, $subject = null) { + $data['table'] = $this; + return parent::dispatchEvent($name, $data, $subject); + } } diff --git a/src/Model/Table/ImageStorageTable.php b/src/Model/Table/ImageStorageTable.php index 16d52fb7..423a8a3c 100644 --- a/src/Model/Table/ImageStorageTable.php +++ b/src/Model/Table/ImageStorageTable.php @@ -1,9 +1,12 @@ dispatchEvent('ImageStorage.beforeSave', [ 'record' => $entity - )); - $this->getEventManager()->dispatch($Event); - if ($Event->isStopped()) { + ]); + if ($imageEvent->isStopped()) { return false; - } +} return true; } @@ -68,17 +71,16 @@ public function beforeSave(\Cake\Event\Event $event, \Cake\ORM\Entity $entity, * Does not call the parent to avoid that the regular file storage event listener saves the image already * * @param \Cake\Event\Event $event - * @param \Burzum\FileStorage\Model\Table\Entity $entity + * @param \Cake\Datasource\EntityInterface $entity * @param array $options * @return boolean */ - public function afterSave(\Cake\Event\Event $event, \Cake\ORM\Entity $entity, $options) { + public function afterSave(Event $event, EntityInterface $entity, $options) { if ($entity->isNew()) { - $imageEvent = new Event('ImageStorage.afterSave', $this, [ - 'storage' => $this->getStorageAdapter($entity['adapter']), - 'record' => $entity + $this->dispatchEvent('ImageStorage.afterSave', [ + 'record' => $entity, + 'storage' => $this->storageAdapter($entity->get('adapter')) ]); - $this->getEventManager()->dispatch($imageEvent); $this->deleteOldFileOnSave($entity); } return true; @@ -88,19 +90,18 @@ public function afterSave(\Cake\Event\Event $event, \Cake\ORM\Entity $entity, $ * Get a copy of the actual record before we delete it to have it present in afterDelete * * @param \Cake\Event\Event $event - * @param \Cake\ORM\Entity $entity + * @param \Cake\Datasource\EntityInterface $entity * @return boolean */ - public function beforeDelete(\Cake\Event\Event $event, \Cake\ORM\Entity $entity) { + public function beforeDelete(Event $event, EntityInterface $entity) { if (!parent::beforeDelete($event, $entity)) { return false; } - $imageEvent = new Event('ImageStorage.beforeDelete', $this, [ + $imageEvent = $this->dispatchEvent('ImageStorage.beforeDelete', [ 'record' => $this->record, - 'storage' => $this->getStorageAdapter($this->record['adapter']) + 'storage' => $this->storageAdapter($this->record['adapter']) ]); - $this->getEventManager()->dispatch($imageEvent); if ($imageEvent->isStopped()) { return false; @@ -115,16 +116,15 @@ public function beforeDelete(\Cake\Event\Event $event, \Cake\ORM\Entity $entity) * Note that we do not call the parent::afterDelete(), we just want to trigger the ImageStorage.afterDelete event but not the FileStorage.afterDelete at the same time! * * @param \Cake\Event\Event $event - * @param \Cake\ORM\Entity $entity + * @param \Cake\Datasource\EntityInterface $entity * @param array $options - * @return void + * @return boolean */ - public function afterDelete(\Cake\Event\Event $event, \Cake\ORM\Entity $entity, $options) { - $imageEvent = new Event('ImageStorage.afterDelete', $this, [ + public function afterDelete(Event $event, EntityInterface $entity, $options) { + $this->dispatchEvent('ImageStorage.afterDelete', [ 'record' => $entity, - 'storage' => $this->getStorageAdapter($entity['adapter']) + 'storage' => $this->storageAdapter($entity->get('adapter')) ]); - $this->getEventManager()->dispatch($imageEvent); return true; } @@ -193,26 +193,25 @@ public function validateImageSize($check, array $options = []) { * already fulfills the purpose. I might rename this event in the 3.0 version of * the plugin to a more generic one. * - * @param array $entity An ImageStorage database record - * @param array $options. Options for the version. + * @param \Cake\Datasource\EntityInterface $entity An ImageStorage database record + * @param array $options Options for the version. * @return array A list of versions for this image file. Key is the version, value is the path or URL to that image. */ - public function getImageVersions($entity, $options = []) { + public function getImageVersions(EntityInterface $entity, $options = []) { $versions = []; - $versionData = (array)Configure::read('FileStorage.imageSizes.' . $entity['model']); + $versionData = (array)Configure::read('FileStorage.imageSizes.' . $entity->get('model')); $versionData['original'] = isset($options['originalVersion']) ? $options['originalVersion'] : 'original'; foreach ($versionData as $version => $data) { - $hash = Configure::read('FileStorage.imageHashes.' . $entity['model'] . '.' . $version); - $Event = new Event('ImageVersion.getVersions', $this, [ + $hash = Configure::read('FileStorage.imageHashes.' . $entity->get('model') . '.' . $version); + $event = $this->dispatchEvent('ImageVersion.getVersions', [ 'hash' => $hash, 'image' => $entity, 'version' => $version, 'options' => [] ] ); - $this->getEventManager()->dispatch($Event); - if ($Event->isStopped()) { - $versions[$version] = str_replace('\\', '/', $Event->data['path']); + if ($event->isStopped()) { + $versions[$version] = str_replace('\\', '/', $event->data['path']); } } return $versions; diff --git a/src/Shell/ImageVersionShell.php b/src/Shell/ImageVersionShell.php index cc26e37e..bbfc7699 100644 --- a/src/Shell/ImageVersionShell.php +++ b/src/Shell/ImageVersionShell.php @@ -6,8 +6,7 @@ use Cake\Event\Event; use Cake\Event\EventManager; use Cake\ORM\TableRegistry; -use Burzum\FileStorage\Lib\StorageManager; -use Burzum\FileStorage\Model\Table\ImageStorageTable; +use Burzum\FileStorage\Storage\StorageManager; /** * ImageShell @@ -70,6 +69,11 @@ public function getOptionParser() { 'short' => 'l', 'help' => __d('file_storage', 'Limits the amount of records to be processed in one batch'), ], + 'keep-old-versions' => [ + 'short' => 'k', + 'help' => __d('file_storage', 'Use this switch if you do not want to overwrite existing versions.'), + 'boolean' => true + ] ], ], ], @@ -116,6 +120,11 @@ public function getOptionParser() { 'short' => 'l', 'help' => __d('file_storage', 'Limits the amount of records to be processed in one batch'), ], + 'keep-old-versions' => [ + 'short' => 'k', + 'help' => __d('file_storage', 'Use this switch if you do not want to overwrite existing versions.'), + 'boolean' => true + ] ], ], ], @@ -126,8 +135,8 @@ public function getOptionParser() { /** * @inheritDoc */ - public function initialize() { - parent::initialize(); + public function startup() { + parent::startup(); $storageTable = 'Burzum/FileStorage.ImageStorage'; if (isset($this->params['storageTable'])) { @@ -136,12 +145,6 @@ public function initialize() { $this->Table = TableRegistry::get($storageTable); - if (!$this->Table instanceOf ImageStorageTable) { - $this->out(__d('file_storage', 'Invalid Storage Table: {0}', $storageTable)); - $this->out(__d('file_storage', 'The table must be an instance of Burzum\FileStorage\Model\Table\ImageStorageTable or extend it!')); - $this->_stop(); - } - if (isset($this->params['limit'])) { if (!is_numeric($this->params['limit'])) { $this->out(__d('file_storage', '--limit must be an integer!')); @@ -154,19 +157,21 @@ public function initialize() { /** * Generate all image versions. * - * @param string $model */ public function regenerate() { $operations = Configure::read('FileStorage.imageSizes.' . $this->args[0]); + $options = [ + 'overwrite' => !$this->params['keep-old-versions'] + ]; if (empty($operations)) { $this->out(__d('file_storage', 'Invalid table or version.')); $this->_stop(); } - foreach ($operations as $operation) { + foreach ($operations as $version => $operation) { try { - $this->_loop($this->command, $this->args[0], array($operation)); + $this->_loop($this->command, $this->args[0], array($version => $operation), $options); } catch (\Exception $e) { $this->out($e->getMessage()); $this->_stop(); @@ -182,6 +187,9 @@ public function regenerate() { */ public function generate($model, $version) { $operations = Configure::read('FileStorage.imageSizes.' . $model . '.' . $version); + $options = [ + 'overwrite' => !$this->params['keep-old-versions'] + ]; if (empty($operations)) { $this->out(__d('file_storage', 'Invalid table or version.')); @@ -189,7 +197,7 @@ public function generate($model, $version) { } try { - $this->_loop('generate', $model, array($version => $operations)); + $this->_loop('generate', $model, array($version => $operations), $options); } catch (\Exception $e) { $this->out($e->getMessage()); $this->_stop(); @@ -221,40 +229,29 @@ public function remove($model, $version) { /** * Loops through image records and performs requested operation on them. * - * @param $action + * @param string $action * @param $model * @param array $operations */ - protected function _loop($action, $model, $operations = array()) { + protected function _loop($action, $model, $operations = [], $options = []) { if (!in_array($action, array('generate', 'remove', 'regenerate'))) { $this->_stop(); } - $this->totalImageCount = $this->Table - ->find() - ->where(['model' => $model]) - ->andWhere(['extension IN' => ['jpg', 'png']]) - ->count(); + $totalImageCount = $this->_getCount($model); - if ($this->totalImageCount == 0) { + if ($totalImageCount === 0) { $this->out(__d('file_storage', 'No Images for model {0} found', $model)); $this->_stop(); } - $this->out(__d('file_storage', '{0} image file(s) will be processed' . "\n", $this->totalImageCount)); + $this->out(__d('file_storage', '{0} image file(s) will be processed' . "\n", $totalImageCount)); $offset = 0; $limit = $this->limit; do { - $images = $this->Table - ->find() - ->where(['model' => $model]) - ->andWhere(['extension IN' => ['jpg', 'png']]) - ->limit($limit) - ->offset($offset) - ->all(); - + $images = $this->_getRecords($model, $limit, $offset); if (!empty($images)) { foreach ($images as $image) { $Storage = StorageManager::adapter($image->adapter); @@ -264,7 +261,11 @@ protected function _loop($action, $model, $operations = array()) { $payload = array( 'record' => $image, 'storage' => $Storage, - 'operations' => $operations); + 'operations' => $operations, + 'versions' => array_keys($operations), + 'table' => $this->Table, + 'options' => $options + ); if ($action == 'generate' || $action == 'regenerate') { $Event = new Event('ImageVersion.createVersion', $this->Table, $payload); @@ -283,4 +284,29 @@ protected function _loop($action, $model, $operations = array()) { $offset += $limit; } while ($images->count() > 0); } + +/** + * Gets the amount of images for a model in the DB. + * + * @param string $identifier + * @param array $extensions + * @return integer + */ + protected function _getCount($identifier, array $extensions = ['jpg', 'png', 'jpeg']) { + return $this->Table + ->find() + ->where(['model' => $identifier]) + ->andWhere(['extension IN' => $extensions]) + ->count(); + } + + protected function _getRecords($identifier, $limit, $offset, array $extensions = ['jpg', 'png', 'jpeg']) { + return $this->Table + ->find() + ->where(['model' => $identifier]) + ->andWhere(['extension IN' => $extensions]) + ->limit($limit) + ->offset($offset) + ->all(); + } } diff --git a/src/Shell/StorageShell.php b/src/Shell/StorageShell.php new file mode 100644 index 00000000..0aff12d4 --- /dev/null +++ b/src/Shell/StorageShell.php @@ -0,0 +1,33 @@ +addSubcommand('image', [ + 'help' => __('Image Processing Task.'), + 'parser' => $this->Image->getOptionParser() + ]); + return $parser; + } +} diff --git a/src/Shell/Task/ImageTask.php b/src/Shell/Task/ImageTask.php new file mode 100644 index 00000000..c7fe7a49 --- /dev/null +++ b/src/Shell/Task/ImageTask.php @@ -0,0 +1,185 @@ + + * bin\cake burzum/FileStorage.storage image remove ProfilePicture "thumb60, crop50" + */ +class ImageTask extends Shell { + + use StorageTrait; + use EventManagerTrait; + + public function initialize() { + $this->Table = TableRegistry::get('Burzum/FileStorage.ImageStorage'); + } + +/** + * Remove image versions. + * + * @return void + */ + public function remove() { + $this->_loop($this->args[0], explode(',', $this->args[1]), 'remove'); + } + +/** + * Create image versions. + * + * @return void + */ + public function generate() { + $this->_loop($this->args[0], explode(',', $this->args[1]), 'generate'); + } + +/** + * Loops through image records and performs requested operations on them. + * + * @param string $identifier + * @return void + */ + protected function _loop($identifier, $options, $action) { + $count = $this->_getCount($identifier); + $offset = 0; + $limit = $this->params['limit']; + + $this->out(__d('file_storage', '{0} record(s) will be processed.' . "\n", $count)); + + do { + $records = $this->_getRecords($identifier, $limit, $offset); + if (!empty($records)) { + foreach ($records as $record) { + $method = '_' . $action . 'Image'; + try { + $this->{$method}($record, $options); + } catch (StorageException $e) { + $this->err($e->getMessage()); + } + } + } + $offset += $limit; + $this->out(__d('file_storage', '{0} of {1} records processed.', [$limit, $count])); + } while ($records->count() > 0); + } + +/** + * Triggers the event to remove image versions. + * + * @param \Cake\ORM\Entity + * @param array + * @return void + */ + protected function _removeImage($record, $options) { + $Event = new Event('ImageVersion.removeVersion', $this->Table, [ + 'record' => $record, + 'operations' => $options + ]); + EventManager::instance()->dispatch($Event); + } + +/** + * Triggers the event to generate the new images. + * + * @param \Cake\ORM\Entity + * @param array + * @return void + */ + protected function _generateImage($record, $options) { + $Event = new Event('ImageVersion.createVersion', $this->Table, [ + 'record' => $record, + 'operations' => $options + ]); + EventManager::instance()->dispatch($Event); + } + +/** + * Gets the records for the loop. + * + * @param string $identifier + * @param integer $limit + * @param integer $offset + * @return \Cake\ORM\ResultSet + */ + public function _getRecords($identifier, $limit, $offset) { + return $this->Table + ->find() + ->where([$this->Table->alias() . '.model' => $identifier]) + ->limit($limit) + ->offset($offset) + ->all(); + } + +/** + * Gets the amount of records for an identifier in the DB. + * + * @param string $identifier + * @return integer + */ + protected function _getCount($identifier) { + $count = $this->_getCountQuery($identifier)->count(); + if ($count === 0) { + $this->out(__d('file_storage', 'No records for identifier "{0}" found.', $identifier)); + $this->_stop(); + } + return $count; + } + +/** + * Gets the query object for the count. + * + * @param string $identifier + * @return \Cake\ORM\Query + */ + protected function _getCountQuery($identifier) { + return $this->Table + ->find() + ->where([$this->Table->alias() . '.model' => $identifier]); + } + + public function getOptionParser() { + $parser = parent::getOptionParser(); + $parser->addOption('model', [ + 'short' => 'm', + 'help' => __('The model to use.'), + 'default' => 'Burzum/FileStorage.ImageStorage' + ]); + $parser->addOption('limit', [ + 'short' => 'l', + 'help' => __('The limit of records to process in a batch.'), + 'default' => 50 + ]); + $parser->addOption('versions', [ + 'short' => 's', + 'help' => __('The model to use.'), + 'default' => 'Burzum/FileStorage.ImageStorage' + ]) + ->addSubcommand('remove', [ + 'remove' => 'Remove image versions.' + ]) + ->addSubcommand('generate', [ + 'remove' => 'Generate image versions.' + ]); + $parser->addArguments([ + 'identifier' => ['help' => 'The identifier to process', 'required' => true], + 'versions' => ['help' => 'The identifier to process', 'required' => true], + ]); + return $parser; + } +} diff --git a/src/Storage/Listener/AbstractListener.php b/src/Storage/Listener/AbstractListener.php new file mode 100644 index 00000000..39ad7447 --- /dev/null +++ b/src/Storage/Listener/AbstractListener.php @@ -0,0 +1,283 @@ + '', + 'pathBuilderOptions' => [], + 'fileHash' => 'sha1', + 'fileField' => 'file', + 'models' => false, + ]; + +/** + * Constructor + * + * @param array $config + */ + public function __construct(array $config = []) { + $this->_mergeListenerVars(); + $this->config($config); + $this->_constructPathBuilder( + $this->_config['pathBuilder'], + $this->_config['pathBuilderOptions'] + ); + $this->initialize($config); + } + +/** + * Merges properties. + * + * @return void + */ + protected function _mergeListenerVars() { + $this->_mergeVars( + ['_defaultConfig'], + ['associative' => ['_defaultConfig']] + ); + } + +/** + * Helper method to bypass the need to override the constructor. + * + * Called last inside __construct() + * + * @return void + */ + public function initialize($config) {} + +/** + * 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 + */ + protected function _checkEvent(Event $event) { + return ( + isset($event->data['table']) + && $event->data['table'] instanceof Table + && $this->getAdapterClassName($event->data['record']['adapter']) + && $this->_modelFilter($event) + ); + } + +/** + * Detects if an entities model field has name of one of the allowed models set. + * + * @param Event $event + * @return boolean + */ + protected function _modelFilter(Event $event) { + if (is_array($this->_config['models'])) { + $model = $event->data['record']['model']; + if (!in_array($model, $this->_config['models'])) { + return false; + } + } + return true; + } + +/** + * Gets the adapter class name from the adapter config + * + * @param string $configName Name of the configuration + * @return boolean|string False if the config is not present + */ + protected function _getAdapterClassFromConfig($configName) { + $config = $this->storageConfig($configName); + if (!empty($config['adapterClass'])) { + return $config['adapterClass']; + } + return false; + } + +/** + * Gets the adapter class name from the adapter configuration key and checks if + * it is in the list of supported adapters for the listener. + * + * You must define a list of supported classes via AbstractStorageEventListener::$_adapterClasses. + * + * @param string $configName Name of the adapter configuration. + * @return string|false String, the adapter class name or false if it was not found. + */ + public function getAdapterClassName($configName) { + $className = $this->_getAdapterClassFromConfig($configName); + if (in_array($className, $this->_adapterClasses)) { + $position = strripos($className, '\\'); + $this->_adapterClass = substr($className, $position + 1, strlen($className)); + return $this->_adapterClass; + } + return false; + } + +/** + * Create a temporary file locally based on a file from an adapter. + * + * A common case is image manipulation or video processing for example. It is + * required to get the file first from the adapter and then write it to + * a tmp file. Then manipulate it and upload the changed file. + * + * The adapter might not be one that is using a local file system, so we first + * get the file from the storage system, store it locally in a tmp file and + * later load the new file that was generated based on the tmp file into the + * storage adapter. This method here just generates the tmp file. + * + * @param Adapter $Storage Storage adapter + * @param string $path Path / key of the storage adapter file + * @param string $tmpFolder + * @throws \Exception + * @return string + */ + protected function _tmpFile($Storage, $path, $tmpFolder = null) { + try { + $tmpFile = $this->createTmpFile($tmpFolder); + file_put_contents($tmpFile, $Storage->read($path)); + return $tmpFile; + } catch (\Exception $e) { + $this->log($e->getMessage()); + throw $e; + } + } + +/** + * Calculates the hash of a file. + * + * You can use this to compare if you got two times the same file uploaded. + * + * @param string $file Path to the file on your local machine. + * @param string $method 'md5' or 'sha1' + * @throws \InvalidArgumentException + * @link http://php.net/manual/en/function.md5-file.php + * @link http://php.net/manual/en/function.sha1-file.php + * @link http://php.net/manual/en/function.sha1-file.php#104748 + * @return string + */ + public function calculateFileHash($file, $method = 'sha1') { + return StorageUtils::getFileHash($file, $method); + } + +/** + * Gets the hash for a file storage entity that is going to be stored. + * + * It first checks if hashing is enabled, if it is enabled it uses the the + * configured hashMethod to generate the hash and returns that hash. + * + * @param \Cake\Datasource\EntityInterface + * @param string $fileField + * @return null|string + */ + public function getFileHash(EntityInterface $entity, $fileField) { + if ($this->config('fileHash') !== false) { + return $this->calculateFileHash( + $entity[$fileField]['tmp_name'], + $this->config('fileHash') + ); + } + return null; + } + +/** + * Creates a temporary file name and checks the tmp path, creates one if required. + * + * This method is thought to be used to generate tmp file locations for use cases + * like audio or image process were you need copies of a file and want to avoid + * conflicts. By default the tmp file is generated using cakes TMP constant + + * folder if passed and a uuid as filename. + * + * @param string $folder + * @param boolean $checkAndCreatePath + * @return string For example /var/www/app/tmp/ or /var/www/app/tmp// + */ + public function createTmpFile($folder = null, $checkAndCreatePath = true) { + if (is_null($folder)) { + $folder = TMP; + } + if ($checkAndCreatePath === true && !is_dir($folder)) { + new Folder($folder, true); + } + return $folder . Text::uuid(); + } + +/** + * Constructs a path builder instance. + * + * @param string $class + * @param array $options + * @return \Burzum\FileStorage\Storage\PathBuilder\PathBuilderInterface + */ + protected function _constructPathBuilder($class, array $options = []) { + $pathBuilder = $this->createPathBuilder($class, $options); + + return $this->pathBuilder($pathBuilder); + } +} diff --git a/src/Storage/Listener/ImageProcessingTrait.php b/src/Storage/Listener/ImageProcessingTrait.php new file mode 100644 index 00000000..27cbed2b --- /dev/null +++ b/src/Storage/Listener/ImageProcessingTrait.php @@ -0,0 +1,265 @@ +_loadImageProcessingFromConfig(); + if (!isset($this->_imageVersions[$entity->model])) { + return false; + } + $method = $action . 'AllImageVersions'; + return $this->{$method}($entity); + } + +/** + * Loads the image processing configuration into the class. + * + * @return void + */ + protected function _loadImageProcessingFromConfig() { + $this->_imageVersions = (array)Configure::read('FileStorage.imageSizes'); + $this->_imageVersionHashes = StorageUtils::generateHashes(); + $this->_defaultOutput = (array)Configure::read('FileStorage.defaultOutput'); + } + +/** + * Gets the image processor instance. + * + * @param array $config + * @return mixed + */ + public function imageProcessor(array $config = [], $renew = false) { + if (!empty($this->_imageProcessor) && $renew === false) { + return $this->_imageProcessor; + } + $this->_loadImageProcessingFromConfig(); + $this->_imageProcessor = new ImageProcessor($config); + return $this->_imageProcessor; + } + +/** + * Gets the hash of a specific image version for an entity. + * + * @param string $model Model identifier. + * @param string $version Version identifier. + * @return string + */ + public function getImageVersionHash($model, $version) { + if (empty($this->_imageVersionHashes[$model][$version])) { + throw new \RuntimeException(sprintf('Version "%s" for identifier "%s" does not exist!', $model, $version)); + } + return $this->_imageVersionHashes[$model][$version]; + } + +/** + * Check that the image versions exist before doing something with them. + * + * @throws \RuntimeException + * @param string $identifier + * @param array $versions + * @return void + */ + protected function _checkImageVersions($identifier, array $versions) { + if (!isset($this->_imageVersions[$identifier])) { + throw new \RuntimeException(sprintf('No image version config found for identifier "%s"!', $identifier)); + } + foreach ($versions as $version) { + if (!isset($this->_imageVersions[$identifier][$version])) { + throw new \RuntimeException(sprintf('Invalid version "%s" for identifier "%s"!', $identifier, $version)); + } + } + } + +/** + * Creates the image versions of an entity. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $versions Versions array. + * @param array $options Imagine save options. + * @return array + */ + public function createImageVersions(EntityInterface $entity, array $versions, array $options = []) { + $this->_checkImageVersions($entity->model, $versions); + + $options += $this->_defaultOutput + [ + 'overwrite' => true + ]; + + $result = []; + $storage = $this->storageAdapter($entity->adapter); + foreach ($this->_imageVersions[$entity->model] as $version => $operations) { + if (!in_array($version, $versions)) { + continue; + } + $saveOptions = $options + ['format' => $entity->extension]; + if (isset($operations['_output'])) { + $saveOptions = $operations['_output'] + $saveOptions; + unset($operations['_output']); + } + + $path = $this->imageVersionPath($entity, $version, 'fullPath', $saveOptions); + + try { + if ($options['overwrite'] || !$storage->has($path)) { + unset($saveOptions['overwrite']); + + $output = $this->createTmpFile(); + $tmpFile = $this->_tmpFile($storage, $this->pathBuilder()->fullPath($entity)); + $this->imageProcessor()->open($tmpFile); + $this->imageProcessor()->batchProcess($output, $operations, $saveOptions); + $storage->write($path, file_get_contents($output), true); + + unlink($tmpFile); + unlink($output); + } + $result[$version] = [ + 'status' => 'success', + 'path' => $path, + 'hash' => $this->getImageVersionHash($entity->model, $version) + ]; + } catch (\Exception $e) { + $result[$version] = [ + 'status' => 'error', + 'error' => $e->getMessage(), + 'line' => $e->getLine(), + 'file' => $e->getFile() + ]; + } + } + return $result; + } + +/** + * Removes image versions of an entity. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array List of image version to remove for that entity. + * @param array $versions + * @param array $options + * @return array + */ + public function removeImageVersions(EntityInterface $entity, array $versions, array $options = []) { + $this->_checkImageVersions($entity->model, $versions); + + $result = []; + foreach ($versions as $version) { + $hash = $this->getImageVersionHash($entity->model, $version); + $path = $this->pathBuilder()->fullPath($entity, ['fileSuffix' => '.' . $hash]); + $result[$version] = [ + 'status' => 'success', + 'hash' => $hash, + 'path' => $path + ]; + try { + $this->storageAdapter($entity->adapter)->delete($path); + } catch (\Exception $e) { + $result[$version]['status'] = 'error'; + $result[$version]['error'] = $e->getMessage(); + } + } + return $result; + } + +/** + * Gets all image version config keys for a specific identifier. + * + * @param string $identifier + * @throws \RuntimeException + * @return array + */ + public function getAllVersionsKeysForModel($identifier) { + if (!isset($this->_imageVersions[$identifier])) { + throw new \RuntimeException(sprintf('No image config present for identifier "%s"!', $identifier)); + } + return array_keys($this->_imageVersions[$identifier]); + } + +/** + * Convenience method to create ALL versions for an entity. + * + * @param \Cake\Datasource\EntityInterface + * @return array + */ + public function createAllImageVersions(EntityInterface $entity, array $options = []) { + return $this->createImageVersions( + $entity, + $this->getAllVersionsKeysForModel($entity->model), + $options + ); + } + +/** + * Convenience method to delete ALL versions for an entity. + * + * @param \Cake\Datasource\EntityInterface + * @return array + */ + public function removeAllImageVersions(EntityInterface $entity, array $options = []) { + return $this->removeImageVersions( + $entity, + $this->getAllVersionsKeysForModel($entity->model), + $options + ); + } + +/** + * Generates image version path / url / filename, etc. + * + * @param \Cake\Datasource\EntityInterface $entity Image entity. + * @param string $version Version name + * @param string $type Path type + * @param array $options PathBuilder options + * @return string + */ + public function imageVersionPath(EntityInterface $entity, $version, $type = 'fullPath', $options = []) { + $hash = $this->getImageVersionHash($entity->model, $version); + + $output = $this->_defaultOutput + ['format' => $entity->extension]; + $operations = $this->_imageVersions[$entity->model][$version]; + if (isset($operations['_output'])) { + $output = $operations['_output'] + $output; + } + + return $this->pathBuilder()->{$type}($entity, $options + [ + 'preserveExtension' => false, + 'fileSuffix' => '.' . $hash . '.' . $output['format'] + ]); + } +} diff --git a/src/Storage/Listener/LegacyLocalFileStorageListener.php b/src/Storage/Listener/LegacyLocalFileStorageListener.php new file mode 100644 index 00000000..1f1f6127 --- /dev/null +++ b/src/Storage/Listener/LegacyLocalFileStorageListener.php @@ -0,0 +1,57 @@ + 'Base', + 'pathBuilderOptions' => [ + 'pathPrefix' => 'files', + 'modelFolder' => false, + 'preserveFilename' => true, + 'randomPath' => 'crc32' + ] + ]; + +/** + * 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()->path($entity); + + if (!$this->_storeFile($event)) { + return; + } + + $event->stopPropagation(); + } + } +} diff --git a/src/Storage/Listener/LocalListener.php b/src/Storage/Listener/LocalListener.php new file mode 100644 index 00000000..f5d1f143 --- /dev/null +++ b/src/Storage/Listener/LocalListener.php @@ -0,0 +1,235 @@ + '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. Only events with an adapter class present in this array will be + * processed. + * + * @var array + */ + public $_adapterClasses = [ + '\Gaufrette\Adapter\Local' + ]; + +/** + * Implemented Events + * + * @return array + */ + public function implementedEvents() { + return [ + '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 + ]; + } + +/** + * 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 + * @throws \Burzum\Filestorage\Storage\StorageException + * @return void + */ + public function afterDelete(Event $event, EntityInterface $entity) { + if ($this->_checkEvent($event)) { + $path = $this->pathBuilder()->fullPath($entity); + try { + if ($this->storageAdapter($entity->adapter)->delete($path)) { + if ($this->_config['imageProcessing'] === true) { + $this->autoProcessImageVersions($entity, 'remove'); + } + $event->result = true; + return; + } + } catch (\Exception $e) { + $this->log($e->getMessage(), LOG_ERR, ['scope' => ['storage']]); + throw new StorageException($e->getMessage()); + } + $event->result = false; + $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', $options); + } + + $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' + ]; + + $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(); + } + +/** + * Stores the file in the configured storage backend. + * + * @param \Cake\Event\Event $event + * @throws \Burzum\Filestorage\Storage\StorageException + * @return boolean + */ + protected function _storeFile(Event $event) { + try { + $fileField = $this->config('fileField'); + $entity = $event->data['record']; + $Storage = $this->storageAdapter($entity['adapter']); + $Storage->write($entity['path'], file_get_contents($entity[$fileField]['tmp_name']), true); + $event->result = $event->data['table']->save($entity, array( + 'checkRules' => false + )); + return true; + } catch (\Exception $e) { + $this->log($e->getMessage(), LogLevel::ERROR, ['scope' => ['storage']]); + throw new StorageException($e->getMessage()); + } + } + +/** + * + */ + public function removeImageVersion(Event $event) { + $this->_processImages($event, 'removeImageVersions'); + } + +/** + * + */ + public function createImageVersion(Event $event) { + $this->_processImages($event, 'createImageVersions'); + } + +/** + * + */ + 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/PathBuilder/BasePathBuilder.php b/src/Storage/PathBuilder/BasePathBuilder.php new file mode 100644 index 00000000..f864cf7d --- /dev/null +++ b/src/Storage/PathBuilder/BasePathBuilder.php @@ -0,0 +1,300 @@ + true, + 'pathPrefix' => '', + 'pathSuffix' => '', + 'filePrefix' => '', + 'fileSuffix' => '', + 'preserveFilename' => false, + 'preserveExtension' => true, + 'uuidFolder' => true, + 'randomPath' => 'sha1', + 'modelFolder' => false + ); + +/** + * Constructor + * + * @param array $config Configuration options. + */ + public function __construct(array $config = []) { + $this->config($config); + } + +/** + * Strips dashes from a string + * + * @param string + * @return string String without the dashed + */ + public function stripDashes($uuid) { + return str_replace('-', '', $uuid); + } + +/** + * Builds the path under which the data gets stored in the storage adapter. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function path(EntityInterface $entity, array $options = []) { + $config = array_merge($this->config(), $options); + $path = ''; + if (!empty($config['pathPrefix']) && is_string($config['pathPrefix'])) { + $path = $config['pathPrefix'] . DS . $path; + } + if ($this->_config['modelFolder'] === true) { + $path .= $entity->model . DS; + } + if ($this->_config['randomPath'] === true) { + $path .= $this->randomPath($entity->id); + } + if (is_string($this->_config['randomPath'])) { + $path .= $this->randomPath($entity->id, 3, $this->_config['randomPath']); + } + // uuidFolder for backward compatibility + if ($this->_config['uuidFolder'] === true || $this->_config['idFolder'] === true) { + $path .= $this->stripDashes($entity->id) . DS; + } + if (!empty($this->_config['pathSuffix']) && is_string($this->_config['pathSuffix'])) { + $path = $path . $this->_config['pathSuffix'] . DS; + } + return $this->ensureSlash($path, 'after'); + } + +/** + * Splits the filename in name and extension. + * + * @param string $filename Filename to split in name and extension. + * @param boolean $keepDot Keeps the dot in front of the extension. + * @return array + */ + public function splitFilename($filename, $keepDot = false) { + $position = strrpos($filename, '.'); + if ($position === false) { + $extension = ''; + } else { + $extension = substr($filename, $position, strlen($filename)); + $filename = substr($filename, 0, $position); + if ($keepDot === false) { + $extension = substr($extension, 1); + } + } + return compact('filename', 'extension'); + } + +/** + * Builds the filename of under which the data gets saved in the storage adapter. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function filename(EntityInterface $entity, array $options = []) { + $config = array_merge($this->config(), $options); + if ($config['preserveFilename'] === true) { + return $this->_preserveFilename($entity, $config); + } + return $this->_buildFilename($entity, $config); + } + +/** + * Used to build a completely customized filename. + * + * The default behavior is to use the UUID from the entities primary key to + * generate a filename based of the UUID that gets the dashes stripped and the + * extension added if you configured the path builder to preserve it. + * + * The filePrefix and fileSuffix options are also supported. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + protected function _buildFilename(EntityInterface $entity, array $options = []) { + $filename = $entity->id; + if ($options['stripUuid'] === true) { + $filename = $this->stripDashes($filename); + } + if (!empty($options['fileSuffix'])) { + $filename = $filename . $options['fileSuffix']; + } + if ($options['preserveExtension'] === true) { + $filename = $filename . '.' . $entity['extension']; + } + if (!empty($options['filePrefix'])) { + $filename = $options['filePrefix'] . $filename; + } + return $filename; + } + +/** + * Keeps the original filename but is able to inject pre- and suffix. + * + * This can be useful to create versions of files for example. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + protected function _preserveFilename(EntityInterface $entity, array $options = []) { + $filename = $entity['filename']; + if (!empty($options['filePrefix'])) { + $filename = $options['filePrefix'] . $entity['filename']; + } + if (!empty($options['fileSuffix'])) { + $split = $this->splitFilename($filename, true); + $filename = $split['filename'] . $options['fileSuffix']; + if ($options['preserveExtension'] === true) { + $filename .= $split['extension']; + } + } + return $filename; + } + +/** + * Returns the path + filename. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function fullPath(EntityInterface $entity, array $options = []) { + return $this->path($entity, $options) . $this->filename($entity, $options); + } + +/** + * Builds the URL under which the file is accessible. + * + * This is for example important for S3 and Dropbox but also the Local adapter + * if you symlink a folder to your webroot and allow direct access to a file. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function url(EntityInterface $entity, array $options = []) { + $url = $this->path($entity) . $this->filename($entity); + return str_replace('\\', '/', $url); + } + +/** + * Creates a semi-random path based on a string. + * + * Makes it possible to overload this functionality. + * + * @param string $string Input string + * @param int $level Depth of the path to generate. + * @param string $method Hash method, crc32 or sha1. + * @return string + */ + public function randomPath($string, $level = 3, $method = 'sha1') { + // Keeping this for backward compatibility but please stop using crc32()! + if ($method === 'crc32') { + return $this->_randomPathCrc32($string, $level); + } + if ($method === 'sha1') { + return $this->_randomPathSha1($string, $level); + } + if (method_exists($this, $method)) { + return $this->{$method}($string, $level); + } + } + +/** + * Creates a semi-random path based on a string. + * + * Please STOP USING CR32! See the huge warning on the php documentation page. + * of the crc32() function. + * + * @link http://php.net/manual/en/function.crc32.php + * @link https://www.box.com/blog/crc32-checksums-the-good-the-bad-and-the-ugly/ + * @param string $string Input string + * @param int $level Depth of the path to generate. + * @return string + */ + protected function _randomPathCrc32($string, $level) { + $string = crc32($string); + $decrement = 0; + $path = null; + for ($i = 0; $i < $level; $i++) { + $decrement = $decrement - 2; + $path .= sprintf("%02d" . DS, substr(str_pad('', 2 * $level, '0') . $string, $decrement, 2)); + } + return $path; + } + +/** + * Creates a semi-random path based on a string. + * + * Makes it possible to overload this functionality. + * + * @param string $string Input string + * @param int $level Depth of the path to generate. + * @return string + */ + protected function _randomPathSha1($string, $level) { + $result = sha1($string); + $randomString = ''; + $counter = 0; + for ($i = 1; $i <= $level; $i++) { + $counter = $counter + 2; + $randomString .= substr($result, $counter, 2) . DS; + } + return $randomString; + } + +/** + * Ensures that a path has a leading and/or trailing (back-) slash. + * + * @param string $string + * @param string $position Can be `before`, `after` or `both` + * @param string $ds Directory separator should be / or \, if not set the DS constant is used. + * @throws \InvalidArgumentException + * @return string + */ + public function ensureSlash($string, $position, $ds = null) { + if (!in_array($position, ['before', 'after', 'both'])) { + throw new \InvalidArgumentException(sprintf('Invalid position `%s`!', $position)); + } + if (is_null($ds)) { + $ds = DS; + } + if ($position === 'before' || $position === 'both') { + if (strpos($string, $ds) !== 0) { + $string = $ds . $string; + } + } + if ($position === 'after' || $position === 'both') { + if (substr($string, -1, 1) !== $ds) { + $string = $string . $ds; + } + } + return $string; + } +} diff --git a/src/Storage/PathBuilder/PathBuilderInterface.php b/src/Storage/PathBuilder/PathBuilderInterface.php new file mode 100644 index 00000000..f41cbdb7 --- /dev/null +++ b/src/Storage/PathBuilder/PathBuilderInterface.php @@ -0,0 +1,51 @@ +_pathBuilder = $pathBuilder; + } + return $this->_pathBuilder; + } +} diff --git a/src/Storage/PathBuilder/S3PathBuilder.php b/src/Storage/PathBuilder/S3PathBuilder.php new file mode 100644 index 00000000..0c77e361 --- /dev/null +++ b/src/Storage/PathBuilder/S3PathBuilder.php @@ -0,0 +1,57 @@ +_defaultConfig['https'] = false; + $this->_defaultConfig['modelFolder'] = true; + $this->_defaultConfig['s3Url'] = 's3.amazonaws.com'; + parent::__construct($config); + } + + protected function _getBucket($adapter) { + $config = StorageManager::config($adapter); + return $config['adapterOptions'][1]; + } + + protected function _buildCloudUrl($bucket, $bucketPrefix = null, $cfDist = null) { + $path = $this->config('https') === true ? 'https://' : 'http://'; + if ($cfDist) { + $path .= $cfDist; + } else { + if ($bucketPrefix) { + $path .= $bucket . '.' . $this->_config['s3Url']; + } else { + $path .= $this->_config['s3Url'] . '/' . $bucket; + } + } + return $path; + } + +/** + * Builds the URL under which the file is accessible. + * + * This is for example important for S3 and Dropbox but also the Local adapter + * if you symlink a folder to your webroot and allow direct access to a file. + * + * @param \Cake\Datasource\EntityInterface $entity + * @param array $options + * @return string + */ + public function url(EntityInterface $entity, array $options = []) { + $bucket = $this->_getBucket($entity->adapter); + $pathPrefix = $this->_buildCloudUrl($bucket); + $path = parent::path($entity); + $path = str_replace('\\', '/', $path); + return $pathPrefix . $path; + } +} diff --git a/src/Storage/StorageException.php b/src/Storage/StorageException.php new file mode 100644 index 00000000..153c3133 --- /dev/null +++ b/src/Storage/StorageException.php @@ -0,0 +1,25 @@ +_entity = $entity; + } + + public function getEntity() { + return $this->_entity; + } +} diff --git a/src/Storage/StorageManager.php b/src/Storage/StorageManager.php new file mode 100644 index 00000000..8d59e3ce --- /dev/null +++ b/src/Storage/StorageManager.php @@ -0,0 +1,119 @@ + [ + 'adapterOptions' => [TMP, true], + 'adapterClass' => '\Gaufrette\Adapter\Local', + 'class' => '\Gaufrette\Filesystem' + ] + ]; + +/** + * Return a singleton instance of the StorageManager. + * + * @return ClassRegistry instance + */ + public static function &getInstance() { + static $instance = array(); + if (!$instance) { + $instance[0] = new StorageManager(); + } + return $instance[0]; + } + +/** + * Gets the configuration array for an adapter. + * + * @param string $adapter + * @param array $options + * @return mixed + */ + public static function config($adapter, $options = array()) { + $_this = StorageManager::getInstance(); + + if (!empty($adapter) && !empty($options)) { + return $_this->_adapterConfig[$adapter] = $options; + } + + if (isset($_this->_adapterConfig[$adapter])) { + return $_this->_adapterConfig[$adapter]; + } + + return false; + } + +/** + * Flush all or a single adapter from the config. + * + * @param string $name Config name, if none all adapters are flushed. + * @throws RuntimeException + * @return boolean True on success + */ + public static function flush($name = null) { + $_this = StorageManager::getInstance(); + + if (isset($_this->_adapterConfig[$name])) { + unset($_this->_adapterConfig[$name]); + return true; + } + + return false; + } + +/** + * StorageAdapter + * + * @param mixed $adapterName string of adapter configuration or array of settings + * @param boolean $renewObject Creates a new instance of the given adapter in the configuration + * @throws RuntimeException + * @return Gaufrette object as configured by first argument + */ + public static function adapter($adapterName, $renewObject = false) { + $_this = StorageManager::getInstance(); + + $isConfigured = true; + if (is_string($adapterName)) { + if (!empty($_this->_adapterConfig[$adapterName])) { + $adapter = $_this->_adapterConfig[$adapterName]; + } else { + throw new \RuntimeException(sprintf('Invalid Storage Adapter %s!', $adapterName)); + } + + if (!empty($_this->_adapterConfig[$adapterName]['object']) && $renewObject === false) { + return $_this->_adapterConfig[$adapterName]['object']; + } + } + + if (is_array($adapterName)) { + $adapter = $adapterName; + $isConfigured = false; + } + + $class = $adapter['adapterClass']; + $Reflection = new \ReflectionClass($class); + if (!is_array($adapter['adapterOptions'])) { + throw new \InvalidArgumentException(sprintf('%s: The adapter options must be an array!', $adapterName)); + } + $adapterObject = $Reflection->newInstanceArgs($adapter['adapterOptions']); + $engineObject = new $adapter['class']($adapterObject); + if ($isConfigured) { + $_this->_adapterConfig[$adapterName]['object'] = &$engineObject; + } + return $engineObject; + } +} diff --git a/src/Storage/StorageTrait.php b/src/Storage/StorageTrait.php new file mode 100644 index 00000000..a150daac --- /dev/null +++ b/src/Storage/StorageTrait.php @@ -0,0 +1,49 @@ +randomPath($string, $level, 'crc32'); + } + +/** + * Helper method to trim last trailing slash in file path + * + * @param string $path Path to trim + * @return string Trimmed path + */ + public static function trimPath($path) { + $len = strlen($path); + if ($path[$len - 1] == '\\' || $path[$len - 1] == '/') { + $path = substr($path, 0, $len - 1); + } + return $path; + } + +/** + * Converts windows to linux pathes and vice versa + * + * @param string + * @return string + */ + public static function normalizePath($string) { + if (DS == '\\') { + return str_replace('/', '\\', $string); + } else { + return str_replace('\\', '/', $string); + } + } + +/** + * Method to normalize the annoying inconsistency of the $_FILE array structure + * + * @link http://www.php.net/manual/en/features.file-upload.multiple.php#109437 + * @param array $array + * @return array Empty array if $_FILE is empty, if not normalize array of Filedata.{n} + */ + public static function normalizeGlobalFilesArray($array = null) { + if (empty($array)) { + $array = $_FILES; + } + $newfiles = array(); + if (!empty($array)) { + foreach ($array as $fieldname => $fieldvalue) { + foreach ($fieldvalue as $paramname => $paramvalue) { + foreach ((array)$paramvalue as $index => $value) { + $newfiles[$fieldname][$index][$paramname] = $value; + } + } + } + } + return $newfiles; + } + +/** + * Serializes and then hashes an array of operations that are applied to an image + * + * @param array $operations + * @return string + */ + public static function hashOperations($operations) { + self::ksortRecursive($operations); + return substr(md5(serialize($operations)), 0, 8); + } + +/** + * Generates the hashes for the different image version configurations. + * + * @param string|array $configPath + * @return array + */ + public static function generateHashes($configPath = 'FileStorage') { + if (is_array($configPath)) { + $imageSizes = $configPath; + } else { + $imageSizes = Configure::read($configPath . '.imageSizes'); + } + if (is_null($imageSizes)) { + throw new \RuntimeException(sprintf('Image processing configuration in "%s" is missing!', $configPath . '.imageSizes')); + } + self::ksortRecursive($imageSizes); + foreach ($imageSizes as $model => $version) { + foreach ($version as $name => $operations) { + Configure::write($configPath . '.imageHashes.' . $model . '.' . $name, self::hashOperations($operations)); + } + } + return Configure::read($configPath . '.imageHashes'); + } + +/** + * Recursive ksort() implementation + * + * @param array $array + * @param integer + * @return boolean + * @link https://gist.github.com/601849 + */ + public static function ksortRecursive(&$array, $sortFlags = SORT_REGULAR) { + if (!is_array($array)) { + return false; + } + ksort($array, $sortFlags); + foreach ($array as &$arr) { + self::ksortRecursive($arr, $sortFlags); + } + return true; + } + +/** + * Returns an array that matches the structure of a regular upload for a local file + * + * @param $file + * @param string File with path + * @return array Array that matches the structure of a regular upload + */ + public static function uploadArray($file, $filename = null) { + $File = new File($file); + if (empty($fileName)) { + $filename = basename($file); + } + return [ + 'name' => $filename, + 'tmp_name' => $file, + 'error' => 0, + 'type' => $File->mime(), + 'size' => $File->size() + ]; + } + +/** + * Gets the hash of a file. + * + * You can use this to compare if you got two times the same file uploaded. + * + * @param string $file Path to the file on your local machine. + * @param string $method 'md5' or 'sha1' + * @throws \InvalidArgumentException + * @link http://php.net/manual/en/function.md5-file.php + * @link http://php.net/manual/en/function.sha1-file.php + * @link http://php.net/manual/en/function.sha1-file.php#104748 + * @return string + */ + public static function getFileHash($file, $method = 'sha1') { + if ($method === 'md5') { + return md5_file($file); + } + if ($method === 'sha1') { + return sha1_file($file); + } + throw new \InvalidArgumentException(sprintf('Invalid hash method "%s" provided!', $method)); + } +} diff --git a/src/TestSuite/FileStorageTestCase.php b/src/TestSuite/FileStorageTestCase.php index a0299e57..6f67fa57 100644 --- a/src/TestSuite/FileStorageTestCase.php +++ b/src/TestSuite/FileStorageTestCase.php @@ -1,17 +1,18 @@ on($listener); - - $listener = new LocalFileStorageListener(); - EventManager::instance()->on($listener); + $this->_setupListeners(); $this->testPath = TMP . 'file-storage-test' . DS; $this->fileFixtures = Plugin::path('Burzum/FileStorage') . 'tests' . DS . 'Fixture' . DS . 'File' . DS; if (!is_dir($this->testPath)) { - $Folder = new Folder($this->testPath, true); + mkdir($this->testPath); } Configure::write('FileStorage.basePath', $this->testPath); @@ -77,13 +75,24 @@ public function setUp() { ] )); - FileStorageUtils::generateHashes(); + StorageUtils::generateHashes(); StorageManager::config('Local', array( 'adapterOptions' => [$this->testPath, true], 'adapterClass' => '\Gaufrette\Adapter\Local', 'class' => '\Gaufrette\Filesystem' )); + + $this->FileStorage = TableRegistry::get('Burzum/FileStorage.FileStorage'); + $this->ImageStorage = TableRegistry::get('Burzum/FileStorage.ImageStorage'); + } + + protected function _setupListeners() { + $this->listeners['ImageProcessingListener'] = new ImageProcessingListener(); + $this->listeners['LocalFileStorageListener'] = new LocalFileStorageListener(); + $this->listeners['LocalListener'] = new LocalListener(); + EventManager::instance()->on($this->listeners['ImageProcessingListener']); + EventManager::instance()->on($this->listeners['LocalFileStorageListener']); } /** @@ -93,8 +102,17 @@ public function setUp() { */ public function tearDown() { parent::tearDown(); - $Folder = new Folder(TMP . 'file-storage-test'); + + $this->_removeListeners(); + + TableRegistry::clear(); + $Folder = new Folder($this->testPath); $Folder->delete(); } + protected function _removeListeners() { + foreach ($this->listeners as $listener) { + EventManager::instance()->off($listener); + } + } } diff --git a/src/Validation/UploadValidator.php b/src/Validation/UploadValidator.php index 11251ecd..a2bd69e7 100644 --- a/src/Validation/UploadValidator.php +++ b/src/Validation/UploadValidator.php @@ -148,7 +148,7 @@ public function imageSize($value, $options) { if (!isset($options['height']) && !isset($options['width'])) { throw new \InvalidArgumentException(__d('file_storage', 'Invalid image size validation parameters!')); } - list($width, $height, $type, $attr) = getimagesize($value['tmp_name']); + list($width, $height) = getimagesize($value['tmp_name']); if (isset($options['height'])) { $validHeight = Validation::comparison($height, $options['height'][1], $options['height'][0]); } @@ -221,35 +221,33 @@ public function uploadErrors($value, $options = array()) { switch ($value['error']) { case UPLOAD_ERR_OK: return true; - break; case UPLOAD_ERR_INI_SIZE: $this->_uploadError = __d('file_storage', 'The uploaded file exceeds limit of %s.', Number::toReadableSize(ini_get('upload_max_filesize'))); - break; + return false; case UPLOAD_ERR_FORM_SIZE: $this->_uploadError = __d('file_storage', 'The uploaded file is to big, please choose a smaller file or try to compress it.'); - break; + return false; case UPLOAD_ERR_PARTIAL: $this->_uploadError = __d('file_storage', 'The uploaded file was only partially uploaded.'); - break; + return false; case UPLOAD_ERR_NO_FILE: if ($options['allowNoFileError'] === false) { $this->_uploadError = __d('file_storage', 'No file was uploaded.'); return false; } return true; - break; case UPLOAD_ERR_NO_TMP_DIR: $this->_uploadError = __d('file_storage', 'The remote server has no temporary folder for file uploads. Please contact the site admin.'); - break; + return false; case UPLOAD_ERR_CANT_WRITE: $this->_uploadError = __d('file_storage', 'Failed to write file to disk. Please contact the site admin.'); - break; + return false; case UPLOAD_ERR_EXTENSION: $this->_uploadError = __d('file_storage', 'File upload stopped by extension. Please contact the site admin.'); - break; + return false; default: $this->_uploadError = __d('file_storage', 'Unknown File Error. Please contact the site admin.'); - break; + return false; } return false; } diff --git a/src/View/Helper/ImageHelper.php b/src/View/Helper/ImageHelper.php index 5adaf724..d35b3d34 100644 --- a/src/View/Helper/ImageHelper.php +++ b/src/View/Helper/ImageHelper.php @@ -63,20 +63,26 @@ public function imageUrl($image, $version = null, $options = []) { $hash = null; } - $Event = new Event('FileStorage.ImageHelper.imagePath', $this, [ - 'hash' => $hash, - 'image' => $image, - 'version' => $version, - 'options' => $options - ] - ); - EventManager::instance()->dispatch($Event); + $eventOptions = [ + 'hash' => $hash, + 'image' => $image, + 'version' => $version, + 'options' => $options, + 'pathType' => 'url' + ]; - if ($Event->isStopped()) { - return $this->normalizePath($Event->data['path']); - } else { - return false; + $event1 = new Event('ImageVersion.getVersions', $this, $eventOptions); + $event2 = new Event('FileStorage.ImageHelper.imagePath', $this, $eventOptions); + + EventManager::instance()->dispatch($event1); + EventManager::instance()->dispatch($event2); + + if ($event1->isStopped()) { + return $this->normalizePath($event1->data['path']); + } elseif ($event2->isStopped()) { + return $this->normalizePath($event2->data['path']); } + return false; } /** diff --git a/tests/Fixture/FileStorageFixture.php b/tests/Fixture/FileStorageFixture.php index 8c841f80..f9ec94a5 100644 --- a/tests/Fixture/FileStorageFixture.php +++ b/tests/Fixture/FileStorageFixture.php @@ -85,6 +85,21 @@ class FileStorageFixture extends TestFixture { 'adapter' => 'Local', 'created' => '2012-01-01 12:00:00', 'modified' => '2012-01-01 12:00:00', + ), + array( + 'id' => 'file-storage-3', + 'user_id' => 'user-1', + 'foreign_key' => 'item-2', + 'model' => 'Item', + 'filename' => 'titus.jpg', + 'filesize' => '335872', + 'mime_type' => 'image/jpg', + 'extension' => 'jpg', + 'hash' => '', + 'path' => '', + 'adapter' => 'Local', + 'created' => '2012-01-01 12:00:00', + 'modified' => '2012-01-01 12:00:00', ) ); } \ No newline at end of file diff --git a/tests/TestCase/Event/LocalFileStorageListenerTest.php b/tests/TestCase/Event/LocalFileStorageListenerTest.php index b4ba9cec..db6b45f0 100644 --- a/tests/TestCase/Event/LocalFileStorageListenerTest.php +++ b/tests/TestCase/Event/LocalFileStorageListenerTest.php @@ -101,7 +101,7 @@ public function testAfterSave() { $entity->file = [ 'tmp_name' => $this->fileFixtures . 'titus.jpg', ]; - $event = new Event('FileStorage.afterDelete', $this->FileStorage, [ + $event = new Event('FileStorage.afterSave', $this->FileStorage, [ 'record' => $entity, ]); $this->Listener->afterSave($event); diff --git a/tests/TestCase/Model/Behavior/Event/EventDispatcherTraitTest.php b/tests/TestCase/Model/Behavior/Event/EventDispatcherTraitTest.php new file mode 100644 index 00000000..9e483515 --- /dev/null +++ b/tests/TestCase/Model/Behavior/Event/EventDispatcherTraitTest.php @@ -0,0 +1,38 @@ +_table = $table; + } + protected function _dispatchEvent($name, $data = null, $subject = null) { + return compact(['name', 'data', 'subject']); + } +} + +class EventDispatcherTraitTest extends TestCase { + +/** + * testSetAndGetEntity + * + * @return void + */ + public function testDispatchEvent() { + $Dispatcher = new TestEventDispatcherTrait('Table goes here.'); + $result = $Dispatcher->dispatchEvent('TestEvent', null, []); + $expected = [ + 'name' => 'TestEvent', + 'data' => [ + 'table' => 'Table goes here.' + ], + 'subject' => [] + ]; + $this->assertEquals($result, $expected); + } +} diff --git a/tests/TestCase/Model/Behavior/FileStorageBehaviorTest.php b/tests/TestCase/Model/Behavior/FileStorageBehaviorTest.php new file mode 100644 index 00000000..99986d93 --- /dev/null +++ b/tests/TestCase/Model/Behavior/FileStorageBehaviorTest.php @@ -0,0 +1,85 @@ +FileStorage); + unset($this->FileStorageBehavior); TableRegistry::clear(); } @@ -57,26 +58,6 @@ public function testBeforeDelete() { $this->assertEquals($this->FileStorage->record, $entity); } -/** - * testBeforeDelete - * - * @return void - */ - public function testGetStorageAdapter() { - $result = $this->FileStorage->getStorageAdapter('Local'); - $this->assertTrue(is_a($result, '\Gaufrette\Filesystem')); - } - -/** - * testGetEventManager - * - * @return void - */ - public function testGetEventManager() { - $result = $this->FileStorage->getEventManager(); - $this->assertTrue(is_a($result, '\Cake\Event\EventManager')); - } - /** * testAfterDelete * @@ -94,13 +75,12 @@ public function testAfterDelete() { } /** - * testBeforeMarshal + * testGetFileInfoFromUpload * * @return void */ - public function testBeforeMarshal() { + public function testGetFileInfoFromUpload() { $filename = \Cake\Core\Plugin::path('Burzum/FileStorage') . DS . 'tests' . DS . 'Fixture' . DS . 'File' . DS . 'titus.jpg'; - $event = new Event('Model.beforeMarshal', $this->FileStorage); $data = new \ArrayObject([ 'file' => [ @@ -109,7 +89,7 @@ public function testBeforeMarshal() { ] ]); - $this->FileStorage->beforeMarshal($event, $data); + $this->FileStorage->getFileInfoFromUpload($data); $this->assertEquals(332643, $data['filesize']); $this->assertEquals('Local', $data['adapter']); @@ -117,4 +97,41 @@ public function testBeforeMarshal() { $this->assertEquals('jpg', $data['extension']); $this->assertEquals('file_storage', $data['model']); } + +/** + * Testing a complete save call + * + * @link https://github.com/burzum/cakephp-file-storage/issues/85 + * @return void + */ + public function testFileSaving() { + $listenersToTest = [ + 'LocalListener', + ]; + $results = []; + foreach ($listenersToTest as $listener) { + $this->_removeListeners(); + EventManager::instance()->on($this->listeners[$listener]); + $entity = $this->FileStorage->newEntity([ + 'model' => 'Document', + 'adapter' => 'Local', + 'file' => [ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($this->fileFixtures . 'titus.jpg'), + 'type' => 'image/jpeg', + 'name' => 'tituts.jpg', + 'tmp_name' => $this->fileFixtures . 'titus.jpg' + ] + ]); + $this->FileStorage->configureUploadValidation([ + 'allowedExtensions' => ['jpg'], + 'validateUploadArray' => true, + 'localFile' => true, + 'validateUploadErrors' => true + ]); + $this->FileStorage->save($entity); + $this->assertEquals($entity->errors(), []); + $results[] = $entity; + } + } } diff --git a/tests/TestCase/Model/Table/ImageStorageTest.php b/tests/TestCase/Model/Table/ImageStorageTableTest.php similarity index 88% rename from tests/TestCase/Model/Table/ImageStorageTest.php rename to tests/TestCase/Model/Table/ImageStorageTableTest.php index 997c21da..f024542e 100644 --- a/tests/TestCase/Model/Table/ImageStorageTest.php +++ b/tests/TestCase/Model/Table/ImageStorageTableTest.php @@ -19,7 +19,7 @@ * @copyright 2012 - 2015 Florian Krämer * @license MIT */ -class ImageStorageTest extends FileStorageTestCase { +class ImageStorageTableTest extends FileStorageTestCase { /** * Fixtures @@ -198,7 +198,7 @@ public function testValidateImageSize() { $result = $this->Image->validateImageSize($file, ['height' => ['<', 100]]); $this->assertFalse($result); } - + /** * testDeleteOldFileOnSave * @@ -223,34 +223,40 @@ public function testDeleteOldFileOnSave() { ]) ->first(); + // Get the old file path to assert late that it doesn't exist anymore + $Folder = new Folder($this->testPath . $result['path']); + $files = $Folder->read(); + $oldFile = $result['path'] . $files[1][2]; + $this->assertTrue(!empty($result) && is_a($result, '\Cake\ORM\Entity')); $this->assertTrue(file_exists($this->testPath . $result['path'])); - - $oldImageFile = $this->testPath . $result['path']; + $secondEntity = $this->Image->newEntity([ 'foreign_key' => 'test-1', 'model' => 'Test', 'file' => [ - 'name' => 'titus.jpg', + 'name' => 'cake.icon.png', 'size' => 332643, - 'tmp_name' => Plugin::path('Burzum/FileStorage') . DS . 'tests' . DS . 'Fixture' . DS . 'File' . DS . 'titus.jpg', + 'tmp_name' => Plugin::path('Burzum/FileStorage') . DS . 'tests' . DS . 'Fixture' . DS . 'File' . DS . 'cake.icon.png', 'error' => 0 ], 'old_file_id' => $entity->id ]); $this->Image->save($secondEntity); - $result = $this->Image->find() + $result2 = $this->Image->find() ->where([ 'id' => $secondEntity->id ]) ->first(); - - $this->assertTrue(!empty($result) && is_a($result, '\Cake\ORM\Entity')); - $this->assertTrue(file_exists($this->testPath . $result['path'])); - - $Folder = new Folder($oldImageFile); - $folderResult = $Folder->read(); - $this->assertEquals(count($folderResult[1]), 0); + + $this->assertTrue(!empty($result) && is_a($result2, '\Cake\ORM\Entity')); + $this->assertFileExists($this->testPath . $result2['path']); + + // Assert that the old file was removed + $this->assertFileNotExists($this->testPath . $oldFile); + + $this->assertNotEquals($result['path'], $result2['path']); + $this->assertNotEquals($result['filename'], $result2['filename']); } } diff --git a/tests/TestCase/Storage/Listener/AbstractListenerTest.php b/tests/TestCase/Storage/Listener/AbstractListenerTest.php new file mode 100644 index 00000000..13a7812e --- /dev/null +++ b/tests/TestCase/Storage/Listener/AbstractListenerTest.php @@ -0,0 +1,39 @@ + 'Base' + ]); + $result = $Listener->pathBuilder(); + $this->assertInstanceOf('\Burzum\FileStorage\Storage\PathBuilder\BasePathBuilder', $result); + } +} diff --git a/tests/TestCase/Storage/Listener/ImageProcessingTraitTest.php b/tests/TestCase/Storage/Listener/ImageProcessingTraitTest.php new file mode 100644 index 00000000..e1567169 --- /dev/null +++ b/tests/TestCase/Storage/Listener/ImageProcessingTraitTest.php @@ -0,0 +1,168 @@ + 'Base', + 'pathBuilderOptions' => [ + 'preserveFilename' => true + ] + ]; + public function __construct(array $config = []) { + parent::__construct($config); + $this->_loadImageProcessingFromConfig(); + } + public function checkImageVersions($identifier, array $versions) { + return $this->_checkImageVersions($identifier, $versions); + } + public function implementedEvents() { + return []; + } +} + +class ImageProcessingTraitTest extends FileStorageTestCase { + +/** + * Fixtures + * + * @var array + */ + public $fixtures = array( + 'plugin.Burzum\FileStorage.FileStorage' + ); + + public function setUp() { + parent::setUp(); + $this->FileStorage = TableRegistry::get('Burzum/FileStorage.FileStorage'); + $this->entity = $this->FileStorage->newEntity([ + 'id' => 'file-storage-1', + 'user_id' => 'user-1', + 'foreign_key' => 'item-1', + 'model' => 'Item', + 'filename' => 'cake.icon.png', + 'filesize' => '', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'hash' => '', + 'path' => '', + 'adapter' => 'Local', + ]); + $this->entity->accessible('id', true); + + Configure::write('FileStorage.imageSizes', [ + 'Item' => [ + 't100' => [ + 'thumbnail' => [ + 'width' => 200, + 'height' => 200 + ] + ], + 'crop50' => [ + 'squareCenterCrop' => [ + 'size' => 300, + ] + ] + ] + ]); + + $this->Listener = $this->getMockBuilder('TraitTestClass') + ->setMethods([ + 'getAdapter' + ]) + ->getMock(); + } + +/** + * testCreateImageVersions + * + * @return void + */ + public function testCreateImageVersions() { + $entity = $this->FileStorage->get('file-storage-3'); + $listener = new TraitTestClass(); + $path = $listener->pathBuilder()->path($entity); + + new Folder($this->testPath . $path, true); + copy($this->fileFixtures . 'titus.jpg', $this->testPath . $path . 'titus.jpg'); + + $listener->imageProcessor(); + $result = $listener->createImageVersions($entity, ['t100', 'crop50']); + $expected = [ + 't100' => [ + 'status' => 'success', + 'path' => '95' . DS . '61' . DS . '80' . DS . 'filestorage3' . DS . 'titus.20876bcd.jpg', + 'hash' => '20876bcd' + ], + 'crop50' => [ + 'status' => 'success', + 'path' => '95' . DS . '61' . DS . '80' . DS . 'filestorage3' . DS . 'titus.41e51a3f.jpg', + 'hash' => '41e51a3f' + ] + ]; + $this->assertEquals($expected, $result); + $this->assertFileExists($this->testPath . '95' . DS . '61' . DS . '80' . DS . 'filestorage3' . DS . 'titus.20876bcd.jpg'); + $this->assertFileExists($this->testPath . '95' . DS . '61' . DS . '80' . DS . 'filestorage3' . DS . 'titus.41e51a3f.jpg'); + + $result = $listener->removeImageVersions($entity, ['t100']); + $expected = [ + 't100' => [ + 'status' => 'success', + 'hash' => '20876bcd', + 'path' => '95' . DS . '61' . DS . '80' . DS . 'filestorage3' . DS . 'titus.20876bcd.jpg' + ] + ]; + $this->assertEquals($result, $expected); + $this->assertFileNotExists($this->testPath . '95' . DS . '61' . DS . '80' . DS . 'filestorage3' . DS . 'titus.20876bcd.jpg'); + $this->assertFileExists($this->testPath . '95' . DS . '61' . DS . '80' . DS . 'filestorage3' . DS . 'titus.41e51a3f.jpg'); + } + +/** + * testCheckImageVersionsRuntimeExceptionIdentifier + * + * @expectedException \RuntimeException + */ + public function testCheckImageVersionsRuntimeExceptionIdentifier() { + $listener = new TraitTestClass(); + $listener->checkImageVersions('does not exist', []); + } + +/** + * testCheckImageVersionsRuntimeExceptionVersion + * + * @expectedException \RuntimeException + */ + public function testCheckImageVersionsRuntimeExceptionVersion() { + $listener = new TraitTestClass(); + $listener->checkImageVersions('Item', ['does not exist!']); + } + +/** + * getAllVersionsKeysForModel + * + * @return void + */ + public function testGetAllVersionsKeysForModel() { + $listener = new TraitTestClass(); + $result = $listener->getAllVersionsKeysForModel('Item'); + $expected = [ + 0 => 't100', + 1 => 'crop50' + ]; + $this->assertEquals($result, $expected); + } +} diff --git a/tests/TestCase/Storage/Listener/LegacyLocalFileStorageListenerTest.php b/tests/TestCase/Storage/Listener/LegacyLocalFileStorageListenerTest.php new file mode 100644 index 00000000..a7749362 --- /dev/null +++ b/tests/TestCase/Storage/Listener/LegacyLocalFileStorageListenerTest.php @@ -0,0 +1,95 @@ +fileFixtures = Plugin::path('Burzum/FileStorage') . 'tests' . DS . 'Fixture' . DS . 'File' . DS; + + $this->listener = $this->getMockBuilder('Burzum\FileStorage\Storage\Listener\LegacyLocalFileStorageListener') + ->setMethods([ + 'storageAdapter' + ]) + ->setConstructorArgs([ + [ + 'models' => ['Item'] + ] + ]) + ->getMock(); + + $this->adapterMock = $this->getMock('\Gaufrette\Adapter\Local', [], ['']); + + $this->FileStorage = TableRegistry::get('Burzum/FileStorage.FileStorage'); + } + +/** + * Testing that the path is the same as in the old LocalFileStorageListener class. + * + * @return void + */ + public function testPath() { + $entity = $this->FileStorage->get('file-storage-1'); + $result = $this->listener->pathBuilder()->path($entity); + $expected = 'files' . DS . '00' . DS . '14' . DS . '90' . DS . 'filestorage1' . DS; + $this->assertEquals($result, $expected); + } + +/** + * testAfterSave + * + * @return void + */ + public function testAfterSave() { + $entity = $this->FileStorage->newEntity([ + 'model' => 'Item', + 'id' => '06c0e8e2-4424-11e5-a151-feff819cdc9f', + 'filename' => 'titus.jpg', + 'extension' => 'jpg', + 'mime_type' => 'image/jpeg', + 'file' => [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $this->fileFixtures . 'titus.jpg' + ] + ]); + + $event = new Event('FileStorage.afterSave', $this->FileStorage, [ + 'record' => $entity, + 'table' => $this->FileStorage + ]); + + $this->listener->expects($this->at(0)) + ->method('storageAdapter') + ->will($this->returnValue($this->adapterMock)); + + $this->adapterMock->expects($this->at(0)) + ->method('write') + ->will($this->returnValue(true)); + + $this->listener->afterSave($event, $entity); + $this->assertEquals($entity->path, 'files' . DS . '05' . DS . '17' . DS . '68' . DS . '06c0e8e2442411e5a151feff819cdc9f' . DS); + } +} \ No newline at end of file diff --git a/tests/TestCase/Storage/Listener/LocalListenerTest.php b/tests/TestCase/Storage/Listener/LocalListenerTest.php new file mode 100644 index 00000000..a8ded1df --- /dev/null +++ b/tests/TestCase/Storage/Listener/LocalListenerTest.php @@ -0,0 +1,95 @@ +fileFixtures = Plugin::path('Burzum/FileStorage') . 'tests' . DS . 'Fixture' . DS . 'File' . DS; + + $this->listener = $this->getMockBuilder('Burzum\FileStorage\Storage\Listener\LocalListener') + ->setMethods(['storageAdapter']) + ->setConstructorArgs([['models' => ['Item']]]) + ->getMock(); + + $this->adapterMock = $this->getMock('\Gaufrette\Adapter\Local', [], ['']); + + $this->FileStorage = TableRegistry::get('Burzum/FileStorage.FileStorage'); + } + +/** + * testAfterSave + * + * @return void + */ + public function testAfterSave() { + $entity = $this->FileStorage->get('file-storage-3'); + $entity->isNew(true); + $entity->file = [ + 'name' => 'titus.jpg', + 'tmp_name' => $this->fileFixtures . 'titus.jpg' + ]; + $event = new Event('FileStorage.afterSave', $this->FileStorage, [ + 'record' => $entity, + 'table' => $this->FileStorage + ]); + + $this->listener->expects($this->at(0)) + ->method('storageAdapter') + ->will($this->returnValue($this->adapterMock)); + + $this->adapterMock->expects($this->at(0)) + ->method('write') + ->will($this->returnValue(true)); + + $this->listener->afterSave($event, $entity); + $this->assertTrue($event->result); + } + +/** + * testAfterDelete + * + * @return void + */ + public function testAfterDelete() { + $entity = $this->FileStorage->get('file-storage-3'); + $event = new Event('FileStorage.afterDelete', $this->FileStorage, [ + 'record' => $entity, + 'table' => $this->FileStorage + ]); + + $this->listener->expects($this->at(0)) + ->method('storageAdapter') + ->will($this->returnValue($this->adapterMock)); + + $this->adapterMock->expects($this->at(0)) + ->method('delete') + ->will($this->returnValue(true)); + + $this->listener->afterDelete($event, $entity); + $this->assertTrue($event->result); + } +} diff --git a/tests/TestCase/Storage/PathBuilder/BasePathBuilderTest.php b/tests/TestCase/Storage/PathBuilder/BasePathBuilderTest.php new file mode 100644 index 00000000..394d3847 --- /dev/null +++ b/tests/TestCase/Storage/PathBuilder/BasePathBuilderTest.php @@ -0,0 +1,151 @@ +FileStorage = TableRegistry::get('Burzum/FileStorage.FileStorage'); + $this->entity = $this->FileStorage->newEntity([ + 'id' => 'file-storage-1', + 'user_id' => 'user-1', + 'foreign_key' => 'item-1', + 'model' => 'Item', + 'filename' => 'cake.icon.png', + 'filesize' => '', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'hash' => '', + 'path' => '', + 'adapter' => 'Local', + ]); + $this->entity->accessible('id', true); + } + +/** + * testPathbuilding + * + * @return void + */ + public function testPathbuilding() { + $builder = new BasePathBuilder(); + $config = $builder->config(); + + $result = $builder->filename($this->entity); + $this->assertEquals($result, 'filestorage1.png'); + + $result = $builder->path($this->entity); + $this->assertEquals($result, '14' . DS . '83' . DS . '23' . DS . 'filestorage1' . DS); + + $result = $builder->fullPath($this->entity); + $this->assertEquals($result, '14' . DS . '83' . DS . '23' . DS . 'filestorage1' . DS . 'filestorage1.png'); + + $builder->config('pathPrefix', 'files'); + $result = $builder->path($this->entity); + $this->assertEquals($result, 'files' . DS . '14' . DS . '83' . DS . '23' . DS . 'filestorage1' . DS); + + $builder->config('pathPrefix', 'files'); + $result = $builder->filename($this->entity); + $this->assertEquals($result, 'filestorage1.png'); + + $builder->config('preserveFilename', true); + $result = $builder->filename($this->entity); + $this->assertEquals($result, 'cake.icon.png'); + + $builder->config($config); + $builder->config('pathSuffix', 'files'); + $result = $builder->path($this->entity); + $this->assertEquals($result, '14' . DS . '83' . DS . '23' . DS . 'filestorage1' . DS . 'files' . DS); + + $result = $builder->url($this->entity); + $expected = '14/83/23/filestorage1/files/filestorage1.png'; + $this->assertEquals($result, $expected); + } + +/** + * testRandomPath + * + * @return void + */ + public function testRandomPath() { + $builder = new BasePathBuilder(); + $result = $builder->randomPath('test', 5, 'sha1'); + $this->assertEquals($result, '4a' . DS . '8f' . DS . 'e5' . DS . 'cc' . DS . 'b1' . DS); + + $result = $builder->randomPath('test', 3, 'sha1'); + $this->assertEquals($result, '4a' . DS . '8f' . DS . 'e5' . DS); + } + +/** + * testEnsureSlash + * + * @return void + */ + public function testEnsureSlash() { + $string = 'foo/bar'; + $builder = new BasePathBuilder(); + $result = $builder->ensureSlash($string, 'both'); + $this->assertEquals($result, DS . $string . DS); + + $result = $builder->ensureSlash(DS . $string . DS, 'both'); + $this->assertEquals($result, DS . $string . DS); + } + +/** + * testEnsureSlashInvalidArgumentException + * + * @expectedException \InvalidArgumentException + */ + public function testEnsureSlashInvalidArgumentException() { + $string = 'foo/bar'; + $builder = new BasePathBuilder(); + $builder->ensureSlash($string, 'INVALID!'); + } + +/** + * testSplitFilename + * + * @return void + */ + public function testSplitFilename() { + $builder = new BasePathBuilder(); + $result = $builder->splitFilename('some.fancy.name.jpg'); + $expected = [ + 'filename' => 'some.fancy.name', + 'extension' => 'jpg' + ]; + $this->assertEquals($result, $expected); + + $result = $builder->splitFilename('no-extension'); + $expected = [ + 'filename' => 'no-extension', + 'extension' => '' + ]; + $this->assertEquals($result, $expected); + } + +/** + * testStripDashes + * + * @return void + */ + public function testStripDashes() { + $builder = new BasePathBuilder(); + $result = $builder->stripDashes('with-dashes-!'); + $this->assertEquals($result, 'withdashes!'); + } +} diff --git a/tests/TestCase/Storage/PathBuilder/PathBuilderTraitTest.php b/tests/TestCase/Storage/PathBuilder/PathBuilderTraitTest.php new file mode 100644 index 00000000..8f6d7d22 --- /dev/null +++ b/tests/TestCase/Storage/PathBuilder/PathBuilderTraitTest.php @@ -0,0 +1,60 @@ +getObjectForTrait('Burzum\FileStorage\Storage\PathBuilder\PathBuilderTrait'); + + $pathBuilder = $object->createPathBuilder('Base'); + $this->assertInstanceOf('Burzum\FileStorage\Storage\PathBuilder\PathBuilderInterface', $pathBuilder); + $this->assertInstanceOf('Burzum\FileStorage\Storage\PathBuilder\BasePathBuilder', $pathBuilder); + } + +/** + * Test createPathBuilder() method with invalid class. + * + * @return void + * @expectedException RuntimeException + * @expectedExceptionMessage Path builder class "\stdClass" does not implement the PathBuilderInterface interface! + */ + public function testCreatePathBuilderInvalidClass() { + $object = $this->getObjectForTrait('Burzum\FileStorage\Storage\PathBuilder\PathBuilderTrait'); + + $object->createPathBuilder('\stdClass'); + } + +/** + * Test createPathBuilder() method with missing class. + * + * @return void + * @expectedException RuntimeException + * @expectedExceptionMessage Could not find path builder "Foo"! + */ + public function testCreatePathBuilderMissingClass() { + $object = $this->getObjectForTrait('Burzum\FileStorage\Storage\PathBuilder\PathBuilderTrait'); + + $object->createPathBuilder('Foo'); + } + +/** + * Test pathBuilder() method. + * + * @return void + */ + public function testPathBuilder() { + $object = $this->getObjectForTrait('Burzum\FileStorage\Storage\PathBuilder\PathBuilderTrait'); + $pathBuilder = $this->getMock('Burzum\FileStorage\Storage\PathBuilder\PathBuilderInterface'); + + $object->pathBuilder($pathBuilder); + + $this->assertSame($pathBuilder, $object->pathBuilder()); + } +} diff --git a/tests/TestCase/Storage/PathBuilder/S3PathBuilderTest.php b/tests/TestCase/Storage/PathBuilder/S3PathBuilderTest.php new file mode 100644 index 00000000..ebe8ab9f --- /dev/null +++ b/tests/TestCase/Storage/PathBuilder/S3PathBuilderTest.php @@ -0,0 +1,55 @@ +FileStorage = TableRegistry::get('Burzum/FileStorage.FileStorage'); + $this->entity = $this->FileStorage->newEntity([ + 'id' => 'file-storage-1', + 'user_id' => 'user-1', + 'foreign_key' => 'item-1', + 'model' => 'Item', + 'filename' => 'cake.icon.png', + 'filesize' => '', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'hash' => '', + 'path' => '', + 'adapter' => 'S3', + ]); + $this->entity->accessible('id', true); + } + +/** + * testUrl + * + * @return void + * @todo finish me + */ + public function testUrl() { + $builder = new S3PathBuilder(); + $result = $builder->url($this->entity); + //debug($result); + } +} diff --git a/tests/TestCase/Storage/StorageExceptionTest.php b/tests/TestCase/Storage/StorageExceptionTest.php new file mode 100644 index 00000000..86907719 --- /dev/null +++ b/tests/TestCase/Storage/StorageExceptionTest.php @@ -0,0 +1,22 @@ +newEntity([]); + $exception = new StorageException(); + $exception->setEntity($entity); + $this->assertEquals($exception->getEntity(), $entity); + } +} diff --git a/tests/TestCase/Lib/StorageManagerTest.php b/tests/TestCase/Storage/StorageManagerTest.php similarity index 96% rename from tests/TestCase/Lib/StorageManagerTest.php rename to tests/TestCase/Storage/StorageManagerTest.php index ec48ec8f..295b485e 100644 --- a/tests/TestCase/Lib/StorageManagerTest.php +++ b/tests/TestCase/Storage/StorageManagerTest.php @@ -9,7 +9,7 @@ namespace Burzum\FileStorage\Test\TestCase\Lib; use Burzum\FileStorage\TestSuite\FileStorageTestCase; -use Burzum\FileStorage\Lib\StorageManager;; +use Burzum\FileStorage\Storage\StorageManager; class StorageManagerTest extends FileStorageTestCase { /** diff --git a/tests/TestCase/Storage/StorageTraitTest.php b/tests/TestCase/Storage/StorageTraitTest.php new file mode 100644 index 00000000..37615ea8 --- /dev/null +++ b/tests/TestCase/Storage/StorageTraitTest.php @@ -0,0 +1,53 @@ +StorageTrait = new TestStorageTrait(); + } + +/** + * testBeforeDelete + * + * @return void + */ + public function testGetStorageAdapter() { + $result = $this->StorageTrait->storageAdapter('Local'); + $this->assertTrue(is_a($result, '\Gaufrette\Filesystem')); + } + +/** + * testBeforeDelete + * + * @return void + */ + public function testGetStorageManagerInstance() { + $result = $this->StorageTrait->storageManager(); + $this->assertTrue(is_a($result, '\Burzum\FileStorage\Storage\StorageManager')); + } +} diff --git a/tests/TestCase/Lib/Utility/FileStorageUtilsTest.php b/tests/TestCase/Storage/StorageUtilsTest.php similarity index 54% rename from tests/TestCase/Lib/Utility/FileStorageUtilsTest.php rename to tests/TestCase/Storage/StorageUtilsTest.php index 78a41dbb..ac97929f 100644 --- a/tests/TestCase/Lib/Utility/FileStorageUtilsTest.php +++ b/tests/TestCase/Storage/StorageUtilsTest.php @@ -2,17 +2,23 @@ namespace Burzum\FileStorage\Test\TestCase\Lib\Utility; use Cake\Core\Configure; +use Cake\Core\Plugin; use Burzum\FileStorage\TestSuite\FileStorageTestCase; -use Burzum\FileStorage\Lib\FileStorageUtils; +use Burzum\FileStorage\Storage\StorageUtils; /** - * StorageManagerTest + * Storage Utils Test * * @author Florian Krämer * @copyright 2012 - 2015 Florian Krämer * @license MIT */ -class FileStorageUtilsTest extends FileStorageTestCase { +class StorageUtilsTest extends FileStorageTestCase { + + public function setUp() { + parent::setUp(); + $this->fileFixtures = Plugin::path('Burzum/FileStorage') . 'tests' . DS . 'Fixture' . DS . 'File' . DS; + } /** * testRandomPath @@ -20,8 +26,13 @@ class FileStorageUtilsTest extends FileStorageTestCase { * @return void */ public function testRandomPath() { - $result = FileStorageUtils::randomPath('someteststring'); + $this->skipIf(PHP_INT_SIZE === 8); + + $result = StorageUtils::randomPath('someteststring'); $this->assertEquals($result, '38' . DS . '88' . DS . '98' . DS); + + $result = StorageUtils::randomPath('file-storage-3'); + $this->assertEquals($result, '48' . DS . '75' . DS . '05' . DS); } /** @@ -30,7 +41,7 @@ public function testRandomPath() { * @return void */ public function testTrimPath() { - $result = FileStorageUtils::trimPath('foobar/'); + $result = StorageUtils::trimPath('foobar/'); $this->assertEquals($result, 'foobar'); } @@ -41,14 +52,19 @@ public function testTrimPath() { */ public function testNormalizePath() { if (DS == '\\') { - $result = FileStorageUtils::normalizePath('/nice/path/test'); + $result = StorageUtils::normalizePath('/nice/path/test'); $this->assertEquals($result, '\nice\path\test'); } else { - $result = FileStorageUtils::normalizePath('\nice\path\test'); + $result = StorageUtils::normalizePath('\nice\path\test'); $this->assertEquals($result, '/nice/path/test'); } } +/** + * testNormalizeGlobalFilesArray + * + * @return void + */ public function testNormalizeGlobalFilesArray() { $data = array( 'name' => array @@ -109,12 +125,20 @@ public function testNormalizeGlobalFilesArray() { ] ] ]; - $result = FileStorageUtils::normalizeGlobalFilesArray($data); + $result = StorageUtils::normalizeGlobalFilesArray($data); $this->assertEquals($result, $expected); + + $result = StorageUtils::normalizeGlobalFilesArray([]); + $this->assertEquals($result, []); } +/** + * testHashOperations + * + * @return void + */ public function testHashOperations() { - $result = FileStorageUtils::hashOperations(array( + $result = StorageUtils::hashOperations(array( 'mode' => 'inbound', 'width' => 80, 'height' => 80 @@ -122,6 +146,11 @@ public function testHashOperations() { $this->assertEquals($result, '8c70933e'); } +/** + * testGenerateHashes + * + * @return void + */ public function testGenerateHashes() { Configure::write('FileStorage.imageSizes', array( 'Test' => array( @@ -156,11 +185,34 @@ public function testGenerateHashes() { 'small' => '19e760eb' ] ]; - FileStorageUtils::generateHashes(); + StorageUtils::generateHashes(); $result = Configure::read('FileStorage.imageHashes'); $this->assertEquals($result, $expected); } +/** + * testGenerateHashesRuntimeException + * + * @expectedException \RuntimeException + */ + public function testGenerateHashesRuntimeException() { + Configure::write('FileStorage.imageSizes', null); + StorageUtils::generateHashes(); + } + +/** + * testFileExtension + * + * @return void + */ + public function testFileExtension() { + $result = StorageUtils::fileExtension($this->fileFixtures . 'titus.jpg', true); + $this->assertEquals($result, 'jpg'); + + $result = StorageUtils::fileExtension('something.else'); + $this->assertEquals($result, 'else'); + } + /** * testUploadArray * @@ -174,8 +226,29 @@ public function testUploadArray() { 'type' => 'image/jpeg', 'size' => 332643 ]; - $result = FileStorageUtils::uploadArray($this->fileFixtures . 'titus.jpg'); + $result = StorageUtils::uploadArray($this->fileFixtures . 'titus.jpg'); $this->assertEquals($result, $expected); } +/** + * testGetFileHash + * + * @return void + */ + public function testGetFileHash() { + $result = StorageUtils::getFileHash($this->fileFixtures . 'titus.jpg'); + $this->assertEquals($result, 'd68da24d79835d70d5d8a544f62616d0e51af191'); + + $result = StorageUtils::getFileHash($this->fileFixtures . 'titus.jpg', 'md5'); + $this->assertEquals($result, '29574141b2c44cc029828f6c5c6d3cd2'); + } + +/** + * testGetFileHashInvalidArgumentException + * + * @expectedException \InvalidArgumentException + */ + public function testGetFileHashInvalidArgumentException() { + StorageUtils::getFileHash($this->fileFixtures . 'titus.jpg', 'invalid-hash-method!'); + } }