diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..53130a3 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10499c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +composer.lock +/vendor +.env +.updated diff --git a/README.md b/README.md index 4f4f8fd..6146a4e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Laravel Fileable -[![Latest Version on Packagist](https://img.shields.io/packagist/v/toneflix-code/laravel-fileable.svg?style=flat-round)](https://packagist.org/packages/toneflix-code/laravel-fileable) -[![Total Downloads](https://img.shields.io/packagist/dt/toneflix-code/laravel-fileable.svg?style=flat-round)](https://packagist.org/packages/toneflix-code/laravel-fileable) +[![Test & Lint](https://github.com/toneflix/laravel-fileable/actions/workflows/php.yml/badge.svg?branch=dev)](https://github.com/toneflix/laravel-fileable/actions/workflows/php.yml) +[![Latest Stable Version](http://poser.pugx.org/toneflix-code/laravel-fileable/v)](https://packagist.org/packages/toneflix-code/laravel-fileable) [![Total Downloads](http://poser.pugx.org/toneflix-code/laravel-fileable/downloads)](https://packagist.org/packages/toneflix-code/laravel-fileable) [![Latest Unstable Version](http://poser.pugx.org/toneflix-code/laravel-fileable/v/unstable)](https://packagist.org/packages/toneflix-code/laravel-fileable) [![License](http://poser.pugx.org/toneflix-code/laravel-fileable/license)](https://packagist.org/packages/toneflix-code/laravel-fileable) [![PHP Version Require](http://poser.pugx.org/toneflix-code/laravel-fileable/require/php)](https://packagist.org/packages/toneflix-code/laravel-fileable) +[![codecov](https://codecov.io/gh/toneflix/laravel-fileable/graph/badge.svg?token=2O7aFulQ9P)](https://codecov.io/gh/toneflix/laravel-fileable) @@ -15,7 +16,7 @@ You can install the package via composer: composer require toneflix-code/laravel-fileable ``` -## Installation +## Package Discovery Laravel automatically discovers and publishes service providers but optionally after you have installed Laravel Fileable, open your Laravel config file config/app.php and add the following lines. @@ -31,6 +32,16 @@ Add the facade of this package to the $aliases array. 'Fileable' => ToneflixCode\LaravelFileable\Facades\Fileable::class ``` +## Upgrading + +Version 2.x is not compatible with version 1.x, if you are ugrading from version 1.x here are a few notes: + +### Config + +1. If you published the configuration file, remove `image_templates`. Templates are no longer needed, just set you responsive image sizes using the `image_sizes` property. + +2. Add `responsive_image_route` and set the value `route/path/{file}/{size}`, `route/path` can be whatever you want it to be, `{file}/{size}` can be anything you want to name them but both are required. + ## Configuration By default Laravel Fileable `avatar` and `media` directories and symlinks to your `storage/app/public` directories, and also adds the `file` directory to your `storage/app` directory. @@ -103,7 +114,13 @@ class User extends Model ``` -The `fileableLoader()` method accepts and array of `[key => value]` pairs that determines which files should be auto discovered in your request, the `key` should match the name field in your input field E.g ``, the `value` should be an existing collection in your Laravel Fileable configuration. +### fileableLoader. + +The `fileableLoader` is responsible for mapping your model to the required collection and indicates that you want to use Laravel Filable to manage your model files. + +The `fileableLoader()` method accepts an array of `[key => value]` pairs that determines which files should be auto discovered in your request, the `key` should match the name field in your input field E.g ``, the `value` should be an existing collection in your Laravel Fileable configuration. + +#### Single collection initialization. ```php $this->fileableLoader([ @@ -111,7 +128,7 @@ $this->fileableLoader([ ]); ``` -OR +#### Multiple collection initialization. ```php $this->fileableLoader([ @@ -120,19 +137,84 @@ $this->fileableLoader([ ]); ``` -The `fileableLoader()` method also accepts the `key` as a string as the first parameter and the `value` as a string as the second parameter. +#### String parameter initialization. + +The `fileableLoader()` method also accepts the `key` as a string first parameter and the `value` as a string as the second parameter. ```php $this->fileableLoader('avatar', 'default'); ``` -#### Loading|Not Loading default media. +#### Default media. + +COnfigured default files are not loaded by default, to load the default file for the model, the `fileableLoader` exposes a third parameter, the `useDefault` parameter, setting it to true will ensure that your default file is loaded when the model's file is not found or missing. + +```php +$this->fileableLoader('avatar', 'default', true); +``` + +OR -The third parameter of the `fileableLoader()` is a boolean value that determines wether to return null or the default image when the requested file is not found. +```php +$this->fileableLoader([ + 'avatar' => 'avatar', +], 'default', true); +``` #### Supporting old setup (Legacy Mode) -If you had your model running before the introducation of the the Fileable trait, you might still be able to load your existing files by passing a fourth parameter to the `fileableLoader()`, the **Legacy mode** attempts to load media files that had been stored or managed by a different logic before the introduction of the fileable trait. +If you had your model running before the introducation of the the Fileable trait, you might still be able to load your existing files by passing a fourth parameter to the `fileableLoader()`, the **Legacy mode** attempts to load media files that had been stored or managed by a different logic or system before the introduction of the fileable trait. + +```php +$this->fileableLoader('avatar', 'default', true, true); +``` + +OR + +```php +$this->fileableLoader([ + 'avatar' => 'avatar', +], 'default', true, true); +``` + +#### Custom Database field. + +There are times when you may want to use a different file name from your database field name, an instance could be when your request includes two diffrent file requests for different models that have the same database field names, the last parameter of the `fileableLoader` was added to support this scenario. + +The 5th parameter of the `fileableLoader` is a string that should equal to the database field where you want your file reference stored in or an array that maps the request file name to the database field name. + +Take a look at this example. + +```html + +``` + +```php +$this->fileableLoader('admin_avatar', 'default', true, true, 'image'); +``` + +OR + +```php +$this->fileableLoader([ + 'admin_avatar' => 'avatar', +], 'default', true, true, 'image'); +``` + +OR + +```php +$this->fileableLoader([ + 'cover' => 'cover', + 'admin_avatar' => 'avatar', +], 'default', true, true, [ + 'cover' => 'cover_image', + 'admin_avatar' => 'image', +]); +``` + +In the last example, `cover_image` is an existing database field mapped to the `cover` input request file name and `image` is an existing database field mapped to the `admin_avatar` input request file name. + ### Model Events @@ -224,7 +306,6 @@ var_dump($post->responsive_images['banner']); While the library will try to resolve media files from the configured collection, you can also force media file search from collections different from the configured ones by saving the path reference on the database with a `collection:filename.ext` prefix, this will allow the system to look for media files in a collection named `collection` even if the current collection for the model is a collection named `images`; - ### Testing ```bash diff --git a/composer.json b/composer.json index d27878f..97188e3 100644 --- a/composer.json +++ b/composer.json @@ -3,28 +3,33 @@ "description": "Laravel Fileable exposes methods that make handling file upload with Laravel filesystem even easier, it also exposes a trait that automatically handles file uploads for you.", "keywords": [ "toneflix-code", - "laravel-fileable" + "laravel-fileable", + "version 2.x" ], "homepage": "https://github.com/toneflix/laravel-fileable", "license": "MIT", "type": "library", "authors": [ { - "name": "Toneflix Code", - "email": "code@toneflix.com.ng", + "name": "Legacy", + "email": "legacy@toneflix.com.ng", + "homepage": "https://legacy.toneflix.com.ng", "role": "Developer" } ], "require": { - "php": "^8.0|^8.1|8.2", - "laravel/framework": "^9.2|^10.0", - "illuminate/filesystem": "^8.0|~9|~10", - "intervention/image": "^2.7", - "intervention/imagecache": "^2.5|^2.6" + "php": "^8.1|^8.2|^8.3", + "illuminate/filesystem": "^8.1|^9.0|^10.0|^11.0", + "illuminate/support": "^8.1|^9.0|^10.0|^11.0", + "intervention/image": "^3.5" }, "require-dev": { - "orchestra/testbench": "^7.0", - "phpunit/phpunit": "^9.0" + "pestphp/pest": "2.x-dev", + "laravel/pint": "^1.15", + "fakerphp/faker": "^1.23", + "illuminate/contracts": "^9.0|^10.0|^11.0", + "orchestra/testbench": "^8.8", + "pestphp/pest-plugin-laravel": "^2.0" }, "autoload": { "psr-4": { @@ -33,14 +38,18 @@ }, "autoload-dev": { "psr-4": { - "ToneflixCode\\LaravelFileable\\Tests\\": "tests" + "ToneflixCode\\LaravelFileable\\Tests\\": "tests", + "ToneflixCode\\LaravelFileable\\Tests\\Database\\Factories\\": "tests/database/factories" } }, "scripts": { - "test": "vendor/bin/phpunit", + "test": "vendor/bin/pest", "test-coverage": "vendor/bin/phpunit --coverage-html coverage" }, "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + }, "sort-packages": true }, "extra": { @@ -52,5 +61,7 @@ "LaravelFileable": "ToneflixCode\\LaravelFileable\\Facades\\Fileable" } } - } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/config/config.php b/config/config.php index 7bf77db..9def927 100644 --- a/config/config.php +++ b/config/config.php @@ -1,5 +1,6 @@ [ 'avatar' => [ @@ -49,13 +50,12 @@ 'lg-square' => '720x720', 'xl-square' => '1080x1080', ], - 'file_route_secure_middleware' => 'web', + 'file_route_secure_middleware' => 'window_auth', + 'responsive_image_route' => 'images/responsive/{file}/{size}', 'file_route_secure' => 'secure/files/{file}', 'file_route_open' => 'open/files/{file}', - 'image_templates' => [ - ], 'symlinks' => [ public_path('avatars') => storage_path('app/public/avatars'), public_path('media') => storage_path('app/public/media'), ], -]; +]; \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..7d0904f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + ./app + ./src + + + diff --git a/routes/routes.php b/routes/routes.php index 96bf85b..5aa942c 100644 --- a/routes/routes.php +++ b/routes/routes.php @@ -1,11 +1,20 @@ privateFile($file); + return (new Media())->privateFile($file); })->middleware(config('toneflix-fileable.file_route_secure_middleware', []) ?? [])->name('fileable.secure.file'); +// The public image generator route Route::get(config('toneflix-fileable.file_route_open', 'load/images/{file}'), function ($file) { - return (new Media)->privateFile($file); -})->name('fileable.open.file'); \ No newline at end of file + return (new Media())->privateFile($file); +})->name('fileable.open.file'); + +// The responsive images route +Route::get(config('toneflix-fileable.responsive_image_route', 'images/responsive/{size}/{file}'), + function (string $size, string $file) { + return (new Media())->resizeResponse($file, $size); + })->name('imagecache'); diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..f28e884 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/Facades/Fileable.php b/src/Facades/Fileable.php index b8127d9..d51ecee 100644 --- a/src/Facades/Fileable.php +++ b/src/Facades/Fileable.php @@ -6,7 +6,7 @@ use ToneflixCode\LaravelFileable\Media; /** - * @see \ToneflixCode\FileableFacade\Skeleton\SkeletonClass + * @see \ToneflixCode\LaravelFileable\Media */ class Fileable extends Facade { diff --git a/src/FileableServiceProvider.php b/src/FileableServiceProvider.php index 735aba5..3bbcc36 100644 --- a/src/FileableServiceProvider.php +++ b/src/FileableServiceProvider.php @@ -3,6 +3,7 @@ namespace ToneflixCode\LaravelFileable; use Illuminate\Support\ServiceProvider; + use ToneflixCode\LaravelFileable\Intervention\Media1080; use ToneflixCode\LaravelFileable\Intervention\Media1080Square; use ToneflixCode\LaravelFileable\Intervention\Media431; diff --git a/src/Initiator.php b/src/Initiator.php index ca74508..7438445 100644 --- a/src/Initiator.php +++ b/src/Initiator.php @@ -13,7 +13,7 @@ class Initiator * * @return Collection */ - public static function collectionPaths(): array | Collection + public static function collectionPaths(): array|Collection { $deepPaths = collect(config('toneflix-fileable.collections', []))->map(function ($col, $key) { $getPath = Arr::get(config('toneflix-fileable.collections', []), $key.'.path'); @@ -41,7 +41,7 @@ public static function collectionPaths(): array | Collection public static function asset(string $url, $absolute = false): string { if ($absolute) { - return str($url)->replace('http:', request()->isSecure() ? 'https:' :'http:')->toString(); + return str($url)->replace('http:', request()->isSecure() ? 'https:' : 'http:')->toString(); } return request()->isSecure() diff --git a/src/Intervention/Media1080.php b/src/Intervention/Media1080.php deleted file mode 100644 index e92d0bc..0000000 --- a/src/Intervention/Media1080.php +++ /dev/null @@ -1,14 +0,0 @@ -fit(1080, 767); - } -} diff --git a/src/Intervention/Media431.php b/src/Intervention/Media431.php deleted file mode 100644 index 41b2fe4..0000000 --- a/src/Intervention/Media431.php +++ /dev/null @@ -1,14 +0,0 @@ -fit(431, 767); - } -} diff --git a/src/Intervention/Media694.php b/src/Intervention/Media694.php deleted file mode 100644 index f1c2cbf..0000000 --- a/src/Intervention/Media694.php +++ /dev/null @@ -1,14 +0,0 @@ -fit(694, 521); - } -} diff --git a/src/Intervention/Media720.php b/src/Intervention/Media720.php deleted file mode 100644 index a979be2..0000000 --- a/src/Intervention/Media720.php +++ /dev/null @@ -1,14 +0,0 @@ -fit(720, 405); - } -} diff --git a/src/Media.php b/src/Media.php index 2c3e60d..7e97754 100644 --- a/src/Media.php +++ b/src/Media.php @@ -3,34 +3,42 @@ namespace ToneflixCode\LaravelFileable; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Storage; +use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\ImageManager; class Media { + public $namespaces; + public $globalDefault = false; public $default_media = 'media/default.png'; - public $namespaces; + public \Intervention\Image\ImageManager $imageDriver; + + public \Illuminate\Contracts\Filesystem\Filesystem $disk; - public function __construct() + public function __construct($storageDisc = null) { $this->namespaces = config('toneflix-fileable.collections'); + $this->globalDefault = true; - $this->imageDriver = new ImageManager(['driver' => 'gd']); + + $this->imageDriver = new ImageManager(new Driver()); + + $this->disk = Storage::disk($storageDisc ?? Storage::getDefaultDriver()); } /** * Fetch an file from the storage * - * @param string $type - * @param string $src - * @return string * @deprecated 1.1.0 Use getMedia() instead. */ - public function image(string $type, string $src = null): string|null + public function image(string $type, string $src = null): ?string { return $this->getMedia($type, $src); } @@ -40,24 +48,24 @@ public function image(string $type, string $src = null): string|null * * @param string $type The type of file to fetch (This will be the configured collection) * @param string $src The file name - * @param bool $returnPath If true the method will return the relative path of the file - * @param bool $legacyMode If true the method will remove the file path from the $src - * @return string + * @param bool $returnPath If true the method will return the relative path of the file + * @param bool $legacyMode If true the method will remove the file path from the $src */ - public function getMedia(string $type, string $src = null, $returnPath = false, $legacyMode = false): string|null + public function getMedia(string $type, string $src = null, $returnPath = false, $legacyMode = false): ?string { if ($legacyMode) { $src = str($src)->afterLast('/')->__toString(); } - if (str($src)->contains(':') && !str($src)->contains('http')) { + + if (str($src)->contains(':') && ! str($src)->contains('http')) { $type = str($src)->before(':')->__toString(); $src = str($src)->after(':')->__toString(); } - $getPath = Arr::get($this->namespaces, $type . '.path'); - $default = Arr::get($this->namespaces, $type . '.default'); + $getPath = Arr::get($this->namespaces, $type.'.path'); + $default = Arr::get($this->namespaces, $type.'.default'); - $prefix = !str($type)->contains('private.') ? 'public/' : '/'; + $prefix = ! str($type)->contains('private.') ? 'public/' : '/'; if (filter_var($src, FILTER_VALIDATE_URL)) { $port = parse_url($src, PHP_URL_PORT); @@ -66,17 +74,18 @@ public function getMedia(string $type, string $src = null, $returnPath = false, if ($returnPath === true) { return parse_url($src, PHP_URL_PATH); } + return Initiator::asset($url->replace('localhost', request()->getHttpHost()), true); } - if (!$src || !Storage::exists($prefix . $getPath . $src)) { + if (! $src || ! $this->disk->exists($prefix.$getPath.$src)) { if (filter_var($default, FILTER_VALIDATE_URL)) { if ($returnPath === true) { return parse_url($default, PHP_URL_PATH); } return $default; - } elseif (!Storage::exists($prefix . $getPath . $default)) { + } elseif (! $this->disk->exists($prefix.$getPath.$default)) { if ($returnPath === true) { return $this->default_media; } @@ -85,22 +94,23 @@ public function getMedia(string $type, string $src = null, $returnPath = false, } if ($returnPath === true) { - return Initiator::asset($getPath . $default, true); + return Initiator::asset($getPath.$default, true); } - return Initiator::asset($getPath . $default); + return Initiator::asset($getPath.$default); } if ($returnPath === true) { - return Initiator::asset($getPath . $src, true); + return Initiator::asset($getPath.$src, true); } elseif (str($type)->contains('private.')) { - $secure = Arr::get($this->namespaces, $type . '.secure', false) === true ? 'secure' : 'open'; + $secure = Arr::get($this->namespaces, $type.'.secure', false) === true ? 'secure' : 'open'; + return Initiator::asset(route("fileable.{$secure}.file", [ - 'file' => base64url_encode($getPath . $src) + 'file' => base64url_encode($getPath.$src), ]), true); } - return Initiator::asset($getPath . $src); + return Initiator::asset($getPath.$src); } /** @@ -112,10 +122,10 @@ public function getMedia(string $type, string $src = null, $returnPath = false, */ public function exists(string $type, string $src = null): bool { - $getPath = Arr::get($this->namespaces, $type . '.path'); - $prefix = !str($type)->contains('private.') ? 'public/' : '/'; + $getPath = Arr::get($this->namespaces, $type.'.path'); + $prefix = ! str($type)->contains('private.') ? 'public/' : '/'; - if (!$src || !Storage::exists($prefix . $getPath . $src)) { + if (! $src || ! $this->disk->exists($prefix.$getPath.$src)) { return false; } @@ -124,32 +134,31 @@ public function exists(string $type, string $src = null): bool /** * Get the relative path of the file - * * @param string $type * @param string $src * @return string */ - public function getPath(string $type, string $src = null): string|null + public function getPath(string $type, string $src = null): ?string { - $getPath = Arr::get($this->namespaces, $type . '.path'); - $default = Arr::get($this->namespaces, $type . '.default'); - $prefix = !str($type)->contains('private.') ? 'public/' : '/'; + $getPath = Arr::get($this->namespaces, $type.'.path'); + $default = Arr::get($this->namespaces, $type.'.default'); + $prefix = ! str($type)->contains('private.') ? 'public/' : '/'; if (filter_var($src, FILTER_VALIDATE_URL)) { return parse_url($src, PHP_URL_PATH); } - if (!$src || !Storage::exists($prefix . $getPath . $src)) { + if (! $src || ! $this->disk->exists($prefix.$getPath.$src)) { if (filter_var($default, FILTER_VALIDATE_URL)) { return parse_url($default, PHP_URL_PATH); - } elseif (!Storage::exists($prefix . $getPath . $default)) { + } elseif (! $this->disk->exists($prefix.$getPath.$default)) { return $this->default_media; } - return $getPath . $default; + return $getPath.$default; } - return $getPath . $src; + return $getPath.$src; } public function getDefaultMedia(string $type): string @@ -161,22 +170,30 @@ public function getDefaultMedia(string $type): string return $default; } - return Initiator::asset($path . $default); + return Initiator::asset($path.$default); } - public function privateFile($file) + /** + * Render the private file + * + * @return void + */ + public function privateFile(string $file) { $src = base64url_decode($file); - if (Storage::exists($src)) { - $mime = Storage::mimeType($src); + + if ($this->disk->exists($src)) { + $mime = $this->disk->mimeType($src); + // create response and add encoded image data if (str($mime)->contains('image')) { - return response()->file(Storage::path($src), [ + return response()->file($this->disk->path($src), [ 'Cross-Origin-Resource-Policy' => 'cross-origin', 'Access-Control-Allow-Origin' => '*', ]); } else { - $response = Response::make(Storage::get($src)); + $response = Response::make($this->disk->get($src)); + // set headers return $response->header('Content-Type', $mime) ->header('Cross-Origin-Resource-Policy', 'cross-origin') @@ -188,12 +205,9 @@ public function privateFile($file) /** * Fetch a file from the storage * - * @param string $type - * @param string $file_name * @param string $old - * @return string */ - public function save(string $type, string $file_name = null, $old = null, $index = null): string|null + public function save(string $type, string $file_name = null, $old = null, $index = null): ?string { // Get the file path $getPath = Arr::get($this->namespaces, $type . '.path'); @@ -202,10 +216,10 @@ public function save(string $type, string $file_name = null, $old = null, $index $prefix = !str($type)->contains('private.') ? 'public/' : '/'; $request = request(); - $old_path = $prefix . $getPath . $old; + $old_path = $prefix.$getPath.$old; if ($request->hasFile($file_name)) { - if ($old && Storage::exists($old_path) && $old !== 'default.png') { - Storage::delete($old_path); + if ($old && $this->disk->exists($old_path) && $old !== 'default.png') { + $this->disk->delete($old_path); } // If an index is provided get the file from the array by index @@ -219,36 +233,37 @@ public function save(string $type, string $file_name = null, $old = null, $index // Give the file a new name and append extension $rename = rand() . '_' . rand() . '.' . $requestFile->extension(); - // Store the file - $requestFile->storeAs( - $prefix . trim($getPath, '/'), - $rename + $this->disk->putFileAs( + $prefix.$getPath, // Path + $requestFile, // Request File + $rename // Directory ); // Reset the file instance $request->offsetUnset($file_name); // If the file is an image resize it - $size = Arr::get($this->namespaces, $type . '.size'); + $size = Arr::get($this->namespaces, $type.'.size'); + + $mime = $this->disk->mimeType($prefix.$getPath.$rename); - // File extensions that can be proccessed by GD should be handed to GD to handle - $img_exts = collect(['jpg', 'png', 'gif', 'bmp', 'webp']); + $size = Arr::get($this->namespaces, $type.'.size'); - if ($size && $img_exts->contains(strtolower($requestFile->extension()))) { - $this->imageDriver->make(storage_path('app/' . $prefix . $getPath . $rename)) - ->{isset($size[0], $size[1]) ? 'fit' : 'resize'}($size[0] ?? null, $size[1] ?? null, function ($constraint) { - isset($size[0], $size[1]) ? $constraint->upsize() : $constraint->aspectRatio(); - }) + // If the file is an image resize it if size is available + if ($size && str($mime)->contains('image')) { + $this->imageDriver->read($this->disk->path($prefix.$getPath.$rename)) + ->cover(Arr::first($size), Arr::last($size)) ->save(); } // Return the new file name - return $rename; + return $rename; } elseif ($request->has($file_name)) { - if ($old && Storage::exists($old_path) && $old !== 'default.png') { - Storage::delete($old_path); + if ($old && $this->disk->exists($old_path) && $old !== 'default.png') { + $this->disk->delete($old_path); } - return null; + + return null; } // If no file is provided return the old file name @@ -258,28 +273,25 @@ public function save(string $type, string $file_name = null, $old = null, $index /** * Save a base64 encoded image string to storage * - * @param string $type - * @param string|null $encoded_string - * @param string $old - * @return string|null + * @param string $old */ - public function saveEncoded(string $type, string $encoded_string = null, $old = null, $index = null): string|null + public function saveEncoded(string $type, string $encoded_string = null, $old = null, $index = null): ?string { - if (!$encoded_string) { + if (! $encoded_string) { return null; } // Get the file path - $getPath = Arr::get($this->namespaces, $type . '.path'); + $getPath = Arr::get($this->namespaces, $type.'.path'); // Get the file path prefix - $prefix = !str($type)->contains('private.') ? 'public/' : '/'; + $prefix = ! str($type)->contains('private.') ? 'public/' : '/'; - $old_path = $prefix . $getPath . $old; + $old_path = $prefix.$getPath.$old; // Delete the old file - if ($old && Storage::exists($old_path) && $old !== 'default.png') { - Storage::delete($old_path); + if ($old && $this->disk->exists($old_path) && $old !== 'default.png') { + $this->disk->delete($old_path); } // Check if the string has a base64 prefix and remove it @@ -304,31 +316,97 @@ public function saveEncoded(string $type, string $encoded_string = null, $old = ])->get($ext, $ext); // Give the file a new name and append extension - $rename = rand() . '_' . rand() . '.' . $extension; - $path = $prefix . trim($getPath, '/') . '/' . $rename; + $rename = rand().'_'.rand().'.'.$extension; + $path = $prefix.trim($getPath, '/').'/'.$rename; // Store the file - Storage::put($path, base64_decode($encoded_string)); + $this->disk->put($path, base64_decode($encoded_string)); return $rename; } /** - * Delete a file from the storage + * Save or retrieve an image from cache * - * @param string $type - * @param string $src - * @return string + * @return array{cc:string,mm:string}|null + */ + public function cached(string $fileName): ?array + { + $file = null; + $content = null; + + Cache::delete("fileable.$fileName"); + + // Check if the file exists in cache + if (Cache::has("fileable.$fileName")) { + $content = Cache::get("fileable.$fileName"); + } else { + // Loop through all the filesystems.links to find the file + foreach (collect(config('filesystems.links'))->values() as $path) { + $file = collect(File::allFiles($path)) + ->firstWhere(fn ($e) => $e->getFilename() === $fileName && str(File::mimeType($e))->contains('image')); + + if (! $file) { + continue; + } + } + + // Save the file to cache + if ($file) { + $content = ['cc' => $file->getContents(), 'mm' => File::mimeType($file)]; + Cache::put("fileable.$fileName", $content); + } + } + + return $content; + } + + /** + * Fetch the given image from the cache and resize it. + */ + public function resizeResponse(string $fileName, string $size): \Illuminate\Http\Response + { + $cached = $this->cached($fileName); + + if ($cached) { + // Get the image resolution + $res = explode( + 'x', + config( + "toneflix-fileable.image_sizes.$size", + config('toneflix-fileable.image_sizes.md', '694') + ) + ); + + // Resize the image + $resized = $this->imageDriver->read($cached['cc']) + ->cover(Arr::first($res), Arr::last($res)) + ->encode(); + + // Make the Http Response + $response = Response::make($resized); + + // set the headers + return $response->header('Content-Type', $cached['mm']) + ->header('Cross-Origin-Resource-Policy', 'cross-origin') + ->header('Access-Control-Allow-Origin', '*'); + } + + return abort(404); + } + + /** + * Delete a file from the storage */ - public function delete(string $type, string $src = null): string|null + public function delete(string $type, string $src = null): ?string { $getPath = Arr::get($this->namespaces, $type . '.path'); $prefix = !str($type)->contains('private.') ? 'public/' : '/'; $path = $prefix . $getPath . $src; - if ($src && Storage::exists($path) && $src !== 'default.png') { - Storage::delete($path); + if ($src && $this->disk->exists($path) && $src !== 'default.png') { + $this->disk->delete($path); } return $path; diff --git a/src/Traits/Fileable.php b/src/Traits/Fileable.php index bc25593..8edb0a3 100644 --- a/src/Traits/Fileable.php +++ b/src/Traits/Fileable.php @@ -2,10 +2,11 @@ namespace ToneflixCode\LaravelFileable\Traits; -use ToneflixCode\LaravelFileable\Media; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Storage; +use ToneflixCode\LaravelFileable\Media; /** * A collection of usefull model manipulation classes. @@ -14,15 +15,52 @@ trait Fileable { public $namespaces; + /** + * THe prefered disk for this instance + */ + public ?string $disk = null; + + /** + * An array of responsive image breakpoints + * + * @var array + */ public array $sizes; + /** + * The name of the collection where files for the model should be stored in + */ public string $collection = 'image'; + /** + * The field in the DB where the file reference should be saved + * If this is an array, the field should be mapped to the $file_name property + * + * @example db_field $db_field = ['image' => 'avatar']; + * In this case avatar is an existing DB field and image will be the filename from the request + * + * @var string|array|null + */ + public string|array|null $db_field = ''; + + /** + * An array mapping of file name (file request param) and colletion name + * Or a file name (file request param) string + * + * @var string|array + */ public string|array $file_name = 'file'; - public static string $static_collection = 'image'; + /** + * Legacy mode is used to support media files that were saved before the introduction of the fileable trait + */ + protected bool $legacyMode = false; - public static string|array $static_file_name = 'file'; + /** + * Apply default file if no file is found + * If set to false missing files will not be replaced with the default URL + */ + protected bool $applyDefault = false; /** * Legacy mode is used to support media files that were saved before the introduction of the fileable trait @@ -48,45 +86,42 @@ public static function boot() static::registerEvents(); - if (is_array(self::$static_file_name)) { - $images = []; - foreach (self::$static_file_name as $file => $collection) { - static::saved(function ($item) use ($file, $collection) { - $item->saveImage($file, $collection); - }); - - static::deleting(function ($item) use ($file, $collection) { - $item->removeFile($file, $collection); - }); + static::saved(function (Fileable|Model $model) { + if (is_array($model->file_name)) { + foreach ($model->file_name as $file => $collection) { + $model->saveImage($file, $collection); + } + } else { + $model->saveImage($model->file_name, $model->collection); } + }); - return $images; - } else { - static::saved(function ($item) { - $item->saveImage(self::$static_file_name, self::$static_collection); - }); - - static::deleting(function ($item) { - $item->removeFile(self::$static_file_name, self::$static_collection); - }); - } + static::deleting(function (Fileable|Model $model) { + if (is_array($model->file_name)) { + foreach ($model->file_name as $file => $collection) { + $model->removeFile($file, $collection); + } + } else { + $model->removeFile($model->file_name, $model->collection); + } + }); } /** * Returns a list of all bound files. * * @deprecated 1.0.0 Use files instead, will be removed from future versions - */ + */ public function images(): Attribute { return new Attribute( - get: fn() => $this->files, + get: fn () => $this->files, ); } /** * Returns a list of all bound files. - */ + */ public function files(): Attribute { return new Attribute( @@ -176,7 +211,7 @@ public function defaultImage(): Attribute { return Attribute::make( get: fn () => ($this->collection - ? (new Media())->getDefaultMedia($this->collection) + ? (new Media($this->disk))->getDefaultMedia($this->collection) : asset('media/default.jpg') ) ); @@ -190,10 +225,10 @@ public function responsiveImages(): Attribute $images = []; foreach ($this->file_name as $file => $collection) { $images[$file] = collect($this->sizes)->mapWithKeys(function ($size, $key) use ($file, $collection) { - $prefix = ! str($collection)->contains('private.') ? 'public/' : '/'; + $prefix = !str($collection)->contains('private.') ? 'public/' : '/'; $isImage = str(Storage::mimeType($prefix . $this->retrieveFile($file, $collection, true))) - ->contains('image'); + ->contains('image'); if (!$isImage) { return [$key => $this->default_image]; @@ -208,9 +243,9 @@ public function responsiveImages(): Attribute return $images; } else { return collect($this->sizes)->mapWithKeys(function ($size, $key) { - $prefix = ! str($this->collection)->contains('private.') ? 'public/' : '/'; + $prefix = !str($this->collection)->contains('private.') ? 'public/' : '/'; $isImage = str(Storage::mimeType($prefix . $this->retrieveFile($this->file_name, $this->collection, true))) - ->contains('image'); + ->contains('image'); if (!$isImage) { return [$key => $this->default_image]; @@ -227,7 +262,7 @@ public function responsiveImages(): Attribute /** * Returns a list of bound files with a little more detal. - */ + */ public function getFiles(): Attribute { return new Attribute( @@ -235,7 +270,7 @@ public function getFiles(): Attribute if (is_array($this->file_name)) { $files = []; foreach ($this->file_name as $file => $collection) { - $prefix = ! str($collection)->contains('private.') ? 'public/' : '/'; + $prefix = !str($collection)->contains('private.') ? 'public/' : '/'; $file_path = $prefix . $this->retrieveFile($file, $collection, true); $mime = Storage::exists($file_path) ? Storage::mimeType($file_path) : null; @@ -253,13 +288,14 @@ public function getFiles(): Attribute return $files; } else { - $prefix = ! str($this->collection)->contains('private.') ? 'public/' : '/'; + $prefix = !str($this->collection)->contains('private.') ? 'public/' : '/'; $file_path = $prefix . $this->retrieveFile($this->file_name, $this->collection, true); $mime = Storage::exists($file_path) ? Storage::mimeType($file_path) : null; $isImage = str($mime)->contains('image'); $file_url = $this->retrieveFile($this->file_name, $this->collection); + return [$this->file_name => [ 'isImage' => $isImage, 'path' => $file_path, @@ -284,8 +320,6 @@ public static function registerEvents() /** * Register all required dependencies here - * - * @return void */ public function registerFileable(): void { @@ -295,54 +329,54 @@ public function registerFileable(): void /** * All fileable properties should be registered * - * @param string|array $file_name filename | [filename => collection] - * @param string $collection + * @param string|array $file_name filename | [filename => collection] + * @param string $collection The name of the collection where files for the model should be stored in + * @param string $applyDefault If set to false missing files will not be replaced with the default URL + * @param bool $legacyMode Support media files that were saved before the introduction of the fileable trait + * @param string|array|null $db_field The field in the DB where the file reference should be saved * @return void */ public function fileableLoader( string|array $file_name = 'file', string $collection = 'default', bool $applyDefault = false, - bool $legacyMode = false + bool $legacyMode = false, + string|array $db_field = null, ) { - $this->applyDefault = $applyDefault; - $this->legacyMode = $legacyMode; - if (is_array($file_name)) { foreach ($file_name as $file => $collection) { if (is_array($collection)) { - throw new \ErrorException("Your collection should be a string"); + throw new \ErrorException('Your collection should be a string'); } - $collect = Arr::get((new Media())->namespaces, $collection); + $collect = Arr::get((new Media($this->disk))->namespaces, $collection); - if (! in_array($collection, array_keys((new Media())->namespaces)) && !$collect) { + if (!in_array($collection, array_keys((new Media($this->disk))->namespaces)) && !$collect) { throw new \ErrorException("$collection is not a valid collection"); } } } if (is_array($collection)) { - throw new \ErrorException("Your collection should be a string"); + throw new \ErrorException('Your collection should be a string'); } - $collect = Arr::get((new Media())->namespaces, $collection); + $collect = Arr::get((new Media($this->disk))->namespaces, $collection); - if (! in_array($collection, array_keys((new Media())->namespaces)) && !$collect) { + if (!in_array($collection, array_keys((new Media($this->disk))->namespaces)) && !$collect) { throw new \ErrorException("$collection is not a valid collection"); } - + $this->applyDefault = $applyDefault; + $this->legacyMode = $legacyMode; $this->collection = $collection; $this->file_name = $file_name; - self::$static_collection = $this->collection; - self::$static_file_name = $this->file_name; + $this->db_field = $db_field ?? $this->db_field; } /** * Add an image to the storage media collection * * @param string|array $request_file_name - * @param string $collection */ public function saveImage(string|array $file_name = null, string $collection = 'default') { @@ -352,20 +386,26 @@ public function saveImage(string|array $file_name = null, string $collection = ' if (is_array($file_name)) { foreach ($file_name as $file => $collection) { if ($this->checkBase64($request->get($file))) { - $save_name = (new Media())->saveEncoded($collection, $request->get($file), $this->{$file}); + $save_name = (new Media($this->disk)) + ->saveEncoded($collection, $request->get($file), $this->{$this->getFieldName($file)}); } else { - $save_name = (new Media())->save($collection, $file, $this->{$file}); + $save_name = (new Media($this->disk)) + ->save($collection, $file, $this->{$this->getFieldName($file)}); } - $this->{$file} = $save_name; + // This maps to $this->image = $save_name where image is an existing database field + $this->{$this->getFieldName($file)} = $save_name; $this->saveQuietly(); } } else { if ($this->checkBase64($request->get($file_name))) { - $save_name = (new Media())->saveEncoded($collection, $request->get($file_name), $this->{$file_name}); + $save_name = (new Media($this->disk)) + ->saveEncoded($collection, $request->get($file_name), $this->{$this->getFieldName($file_name)}); } else { - $save_name = (new Media())->save($collection, $file_name, $this->{$file_name}); + $save_name = (new Media($this->disk)) + ->save($collection, $file_name, $this->{$this->getFieldName($file_name)}); } - $this->{$file_name} = $save_name; + // This maps to $this->image = $save_name where image is an existing database field + $this->{$this->getFieldName($file_name)} = $save_name; $this->saveQuietly(); } } @@ -378,18 +418,17 @@ public function checkBase64($file): bool /** * Load an image from the storage media collection * - * @param string $file_name - * @param string $collection * @param bool $getPath */ public function retrieveFile(string $file_name = 'file', string $collection = 'default', bool $returnPath = false) { - if ($this->{$file_name}) { - return (new Media())->getMedia($collection, $this->{$file_name}, $returnPath, $this->legacyMode); + if ($this->getFieldName($file_name)) { + return (new Media($this->disk)) + ->getMedia($collection, $this->{$this->getFieldName($file_name)}, $returnPath); } if ($this->applyDefault) { - return (new Media())->getDefaultMedia($collection); + return (new Media($this->disk))->getDefaultMedia($collection); } return null; @@ -397,19 +436,31 @@ public function retrieveFile(string $file_name = 'file', string $collection = 'd /** * Delete an image from the storage media collection - * - * @param string|array $file_name - * @param string $collection */ public function removeFile(string|array $file_name = null, string $collection = 'default') { $file_name = $file_name ?? $this->file_name; if (is_array($file_name)) { foreach ($file_name as $file => $collection) { - return (new Media())->delete($collection, $this->{$file}); + return (new Media($this->disk)) + ->delete($collection, $this->{$this->getFieldName($file)}); } } else { - return (new Media())->delete($collection, $this->{$file_name}); + return (new Media($this->disk)) + ->delete($collection, $this->{$this->getFieldName($file_name)}); } } + + protected function getFieldName(string $file_name): string + { + if (!$this->db_field) { + return $file_name; + } + + if (is_array($this->db_field)) { + return $this->db_field[$file_name] ?? $file_name; + } + + return $this->db_field ?? $file_name; + } } diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 0000000..83d5844 Binary files /dev/null and b/tests/.DS_Store differ diff --git a/tests/Feature/ModelTest.php b/tests/Feature/ModelTest.php new file mode 100644 index 0000000..d57f313 --- /dev/null +++ b/tests/Feature/ModelTest.php @@ -0,0 +1,14 @@ +toBeTrue(); + $user = User::factory()->create(); + + $image = $user->files['avatar'] ?? $user->files['image'] ?? ''; + + expect($image !== '')->toBeTrue(); + expect($image !== null)->toBeTrue(); + // expect(mb_stripos($image, 'default.') === false)->toBeTrue(); +}); diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..5c17b0e --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,44 @@ + + */ + protected $fillable = [ + 'name', + 'image', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + public function registerFileable() + { + $this->fileableLoader([ + 'image' => 'avatar', + ], 'default', true, false, ['image' => 'avatar']); + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..4493574 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,49 @@ +in(__DIR__); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function loadEnv() +{ + // Load the .env file + $dotenv = \Dotenv\Dotenv::createImmutable(__DIR__.'/..'); + $dotenv->safeLoad(); +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..6d7ff31 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,51 @@ +set('app.key', 'base64:EWcFBKBT8lKlGK8nQhTHY+wg19QlfmbhtO9Qnn3NfcA='); + + config()->set('database.default', 'testing'); + + config()->set('app.faker_locale', 'en_NG'); + + $migration = include __DIR__.'/database/migrations/create_users_tables.php'; + $migration->up(); + } + + protected function setUp(): void + { + parent::setUp(); + + Factory::guessFactoryNamesUsing( + fn (string $modelName) => 'ToneflixCode\\LaravelFileable\\Tests\\Database\\Factories\\'. + class_basename( + $modelName + ).'Factory' + ); + } + + protected function getPackageProviders($app) + { + return [ + FileableServiceProvider::class, + ]; + } +} diff --git a/tests/database/.DS_Store b/tests/database/.DS_Store new file mode 100644 index 0000000..6a535d5 Binary files /dev/null and b/tests/database/.DS_Store differ diff --git a/tests/database/factories/UserFactory.php b/tests/database/factories/UserFactory.php new file mode 100644 index 0000000..ea0858b --- /dev/null +++ b/tests/database/factories/UserFactory.php @@ -0,0 +1,26 @@ + $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'phone' => $phones[rand(0, count($phones) - 1)] ?? $this->faker->phoneNumber, + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]; + } +} diff --git a/tests/database/migrations/create_users_tables.php b/tests/database/migrations/create_users_tables.php new file mode 100644 index 0000000..ebfff38 --- /dev/null +++ b/tests/database/migrations/create_users_tables.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->string('phone')->unique(); + $table->string('email')->unique(); + $table->string('avatar')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/tests/image.jpg b/tests/image.jpg new file mode 100644 index 0000000..0ffdb40 Binary files /dev/null and b/tests/image.jpg differ