546 lines
20 KiB
PHP
546 lines
20 KiB
PHP
<?php namespace System\Classes;
|
||
|
||
use App;
|
||
use Url;
|
||
use Crypt;
|
||
use Cache;
|
||
use Event;
|
||
use Config;
|
||
use Storage;
|
||
use SystemException;
|
||
use October\Rain\Database\Attach\File as FileModel;
|
||
|
||
/**
|
||
* $width = numeric, 'auto' | false | null
|
||
* $height = numeric, 'auto' | false | null
|
||
* $options = null | array [
|
||
* 'mode' => [
|
||
* 'auto', // automatically choose between portrait and landscape based on the image's orientation
|
||
* 'exact', // resize to the exact dimensions given, without preserving aspect ratio
|
||
* 'portrait', // resize to the given height and adapt the width to preserve aspect ratio
|
||
* 'landscape', // resize to the given width and adapt the height to preserve aspect ratio
|
||
* 'crop', // crop to the given dimensions after fitting as much of the image as possible inside those
|
||
* 'fit', // fit the image inside the given maximal dimensions, keeping the aspect ratio
|
||
* ],
|
||
* 'quality' => numeric, 1 - 100
|
||
* 'interlace' => boolean (default false),
|
||
* 'extension' => ['auto', 'png', 'gif', 'jpg', 'jpeg', 'webp', 'bmp', 'ico'],
|
||
* 'offset' => [x, y] Offset to crop the image from
|
||
* 'sharpen' => numeric, 1 - 100
|
||
*
|
||
* // Options that could be processed by an addon
|
||
*
|
||
* 'blur' => numeric, 1 - 100
|
||
* 'brightness'=> numeric, -100 - 100
|
||
* 'contrast' => numeric, -100 - 100
|
||
* 'pixelate' => numeric, 1 - 5000
|
||
* 'greyscale' => boolean
|
||
* 'invert' => boolean
|
||
* 'opacity' => numeric, 0 - 100
|
||
* 'rotate' => numeric, 1 - 360
|
||
* 'flip' => [h, v]
|
||
* 'background' | 'fill' => string, hex value
|
||
* 'colourize' => string, RGB value
|
||
* ]
|
||
*
|
||
* Event::fire('system.resizer.afterResize')
|
||
* Event::fire('system.resizer.beforeResize')
|
||
* Event::fire('system.resizer.processResize')
|
||
* Event::fire('system.resizer.getAvailableSources', [&$sourcesArray])
|
||
*
|
||
*
|
||
*/
|
||
|
||
/**
|
||
* DRAFT RESIZER DESIGN
|
||
* This is a rough draft, very WIP, of what the Image resizer UX / API will look like in October.
|
||
*
|
||
* Notes:
|
||
* - Clearing the application cache should not invalidate any existing resized images
|
||
* - Invalid images should not result in a valid "image not found" image existing, it should result in a 404 or more specific error
|
||
* - Provide a new backend list column type "thumb" that will pass it through the resizer
|
||
*
|
||
* Configurations to support
|
||
*
|
||
* - Developer can provide a image (in a wide range of various formats so long as the application actually has access to the provided image
|
||
* and can understand how to access it) to the `| resize(width, height, options)` Twig filter. That filter will output either a link to the
|
||
* final generated image as requested or a link to the resizer route that will actually handle resizing the image.
|
||
* - User should be able to extend the image resizing to provide pre or post processing of the images before / after being resized
|
||
* also to include the ability to swap out the image resizer itself. The core workflow logic should remain the same though.
|
||
* Examples:
|
||
* - Post processing of resized images with TinyPNG to optimize filesize further
|
||
* - Replacement processing of resizing with Intervention Image (using GD or ImageMagick)
|
||
*/
|
||
|
||
/**
|
||
* Image Resizing class used for resizing any image resources accessible
|
||
* to the application.
|
||
*
|
||
* This works by accepting a variety of image sources and normalizing the
|
||
* pipeline for storing the desired resizing configuration and then
|
||
* deferring the actual resizing of the images until requested by the browser.
|
||
*
|
||
* When the resizer route is hit, the configuration is retrieved from the cache
|
||
* and used to generate the desired image and then redirect to the generated images
|
||
* static path to minimize the load on the server. Future loads of the image are
|
||
* automatically pointed to the static URL of the resized image without even hitting
|
||
* the resizer route.
|
||
*
|
||
* The functionality of this class is controlled by these config items:
|
||
*
|
||
* - cms.resized.disk -
|
||
* - cms.resized.folder -
|
||
* - cms.resized.path -
|
||
*
|
||
* @see System\Classes\SystemController System controller
|
||
* @package october\system
|
||
* @author Luke Towers
|
||
*/
|
||
class ImageResizer
|
||
{
|
||
/**
|
||
* @var string The cache key prefix for resizer configs
|
||
*/
|
||
public const CACHE_PREFIX = 'system.resizer.';
|
||
|
||
/**
|
||
* @var string Unique identifier for the current configuration
|
||
*/
|
||
protected $identifier = null;
|
||
|
||
/**
|
||
* @var array Image source data ['disk' => string, 'path' => string, 'source' => string]
|
||
*/
|
||
protected $image = [];
|
||
|
||
/**
|
||
* @var integer Desired width
|
||
*/
|
||
protected $width = null;
|
||
|
||
/**
|
||
* @var integer Desired height
|
||
*/
|
||
protected $height = null;
|
||
|
||
/**
|
||
* @var array Image resizing configuration data
|
||
*/
|
||
protected $options = [];
|
||
|
||
/**
|
||
* Prepare the resizer instance
|
||
*
|
||
* @param mixed $image Supported values below:
|
||
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string],
|
||
* instance of October\Rain\Database\Attach\File,
|
||
* string containing URL or path accessible to the application's filesystem manager
|
||
* @param integer|bool|null $width Desired width of the resized image
|
||
* @param integer|bool|null $height Desired height of the resized image
|
||
* @param array|null $options Array of options to pass to the resizer
|
||
*/
|
||
public function __construct($image, $width = null, $height = null, $options = [])
|
||
{
|
||
$this->image = static::normalizeImage($image);
|
||
$this->width = is_numeric($width) ? (int) $width : null;
|
||
$this->height = is_numeric($height) ? (int) $height : null;
|
||
$this->options = array_merge($this->getDefaultOptions(), $options);
|
||
}
|
||
|
||
/**
|
||
* Instantiate a resizer instance from the provided identifier
|
||
*
|
||
* @param string $identifier The 40 character cache identifier for the desired resizer configuration
|
||
* @throws SystemException If the identifier is unable to be loaded
|
||
* @return static
|
||
*/
|
||
public static function fromIdentifier(string $identifier)
|
||
{
|
||
// Attempt to retrieve the resizer configuration and remove the data from the cache after retrieval
|
||
$config = Cache::get(static::CACHE_PREFIX . $identifier, null); // @TODO: replace with pull()
|
||
|
||
// Validate that the desired config was able to be loaded
|
||
if (empty($config)) {
|
||
throw new SystemException("Unable to retrieve the configuration for " . e($identifier));
|
||
}
|
||
|
||
return new static($config['image'], $config['width'], $config['height'], $config['options']);
|
||
}
|
||
|
||
/**
|
||
* Get the default options for the resizer
|
||
*
|
||
* @return array
|
||
*/
|
||
public function getDefaultOptions()
|
||
{
|
||
// Default options for the built in resizing processor
|
||
$defaultOptions = [
|
||
'mode' => 'auto',
|
||
'offset' => [0, 0],
|
||
'sharpen' => 0,
|
||
'interlace' => false,
|
||
'quality' => 90,
|
||
'extension' => pathinfo($this->image['path'], PATHINFO_EXTENSION),
|
||
];
|
||
|
||
/**
|
||
* @event system.resizer.getDefaultOptions
|
||
* Provides an opportunity to modify the default options used when generating image resize requests
|
||
*
|
||
* Example usage:
|
||
*
|
||
* Event::listen('system.resizer.getDefaultOptions', function ((array) &$defaultOptions)) {
|
||
* $defaultOptions['background'] = '#f2f2f2';
|
||
* });
|
||
*
|
||
*/
|
||
Event::fire('system.resizer.getDefaultOptions', [&$defaultOptions]);
|
||
|
||
return $defaultOptions;
|
||
}
|
||
|
||
/**
|
||
* Get the available sources for processing image resize requests from
|
||
*
|
||
* @return array
|
||
*/
|
||
public static function getAvailableSources()
|
||
{
|
||
$sources = [
|
||
'themes' => [
|
||
'disk' => 'system',
|
||
'folder' => config('cms.themesPathLocal', base_path('themes')),
|
||
'path' => config('cms.themesPath', '/themes'),
|
||
],
|
||
'plugins' => [
|
||
'disk' => 'system',
|
||
'folder' => config('cms.pluginsPathLocal', base_path('plugins')),
|
||
'path' => config('cms.pluginsPath', '/plugins'),
|
||
],
|
||
'media' => [
|
||
'disk' => config('cms.storage.media.disk', 'local'),
|
||
'folder' => config('cms.storage.media.folder', 'media'),
|
||
'path' => config('cms.storage.media.path', '/storage/app/media'),
|
||
],
|
||
'modules' => [
|
||
'disk' => 'system',
|
||
'folder' => base_path('modules'),
|
||
'path' => '/modules',
|
||
],
|
||
'uploads' => [
|
||
'disk' => config('cms.storage.uploads.disk', 'local'),
|
||
'folder' => config('cms.storage.uploads.folder', 'uploads'),
|
||
'path' => config('cms.storage.uploads.path', '/storage/app/uploads'),
|
||
'target' => 'alongside',
|
||
],
|
||
];
|
||
|
||
/**
|
||
* @event system.resizer.getAvailableSources
|
||
* Provides an opportunity to modify the sources available for processing resize requests from
|
||
*
|
||
* Example usage:
|
||
*
|
||
* Event::listen('system.resizer.getAvailableSources', function ((array) &$sources)) {
|
||
* $sources['custom'] = [
|
||
* 'disk' => 'custom',
|
||
* 'folder' => 'relative/path/on/disk',
|
||
* 'path' => 'publicly/accessible/path',
|
||
* ];
|
||
* });
|
||
*
|
||
*/
|
||
Event::fire('system.resizer.getAvailableSources', [&$sources]);
|
||
|
||
return $sources;
|
||
}
|
||
|
||
|
||
public function getConfig()
|
||
{
|
||
return [
|
||
'image' => $this->image,
|
||
'width' => $this->width,
|
||
'height' => $this->height,
|
||
'options' => $this->options,
|
||
];
|
||
}
|
||
|
||
|
||
/**
|
||
* Gets the identifier for provided resizing configuration
|
||
* This method validates, authorizes, and prepares the resizing request for execution by the resizer
|
||
* Invalid images (inaccessible, private, incompatible formats, etc) should be denied here, and only
|
||
* after successfull validation should the requested configuration be stored along with a signed hash
|
||
* of the the options
|
||
*
|
||
* @return string 40 character string used as a unique reference to the provided configuration
|
||
*/
|
||
public function getIdentifier()
|
||
{
|
||
if ($this->identifier) {
|
||
return $this->identifier;
|
||
}
|
||
|
||
// Generate the identifier
|
||
$this->identifier = hash_hmac('sha1', $this->getResizedUrl(), Crypt::getKey());
|
||
|
||
// If the image hasn't been resized yet, then store the config data for the resizer to use
|
||
if (!$this->isResized()) {
|
||
// @TODO: remove the cache timeout when testing in Laravel 6, L5.5 didn't support rememberForever in put
|
||
Cache::put(static::CACHE_PREFIX . $this->identifier, $this->getConfig(), now()->addMinutes(10));
|
||
}
|
||
|
||
return $this->identifier;
|
||
}
|
||
|
||
/**
|
||
* Normalize the provided input into information that the resizer can work with
|
||
*
|
||
* @param mixed $image Supported values below:
|
||
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string],
|
||
* instance of October\Rain\Database\Attach\File,
|
||
* string containing URL or path accessible to the application's filesystem manager
|
||
* @throws SystemException If the image was unable to be identified
|
||
* @return array Array containing the disk, path, and extension ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string]
|
||
*/
|
||
public static function normalizeImage($image)
|
||
{
|
||
$disk = null;
|
||
$path = null;
|
||
|
||
// Process an array
|
||
if (is_array($image) && !empty($image['disk']) && !empty($image['path'])) {
|
||
$disk = $image['disk'];
|
||
$path = $image['path'];
|
||
|
||
// Verify that the source file exists
|
||
if (empty(pathinfo($path, PATHINFO_EXTENSION)) || !$disk->exists($path)) {
|
||
$disk = null;
|
||
$path = null;
|
||
}
|
||
|
||
// Process a FileModel
|
||
} elseif ($image instanceof FileModel) {
|
||
$disk = $image->getDisk();
|
||
$path = $image->getDiskPath();
|
||
|
||
// Verify that the source file exists
|
||
if (empty(pathinfo($path, PATHINFO_EXTENSION)) || !$disk->exists($path)) {
|
||
$disk = null;
|
||
$path = null;
|
||
}
|
||
|
||
// Process a string
|
||
} elseif (is_string($image)) {
|
||
// Parse the provided image path into a filesystem ready relative path
|
||
$relativePath = urldecode(parse_url($image, PHP_URL_PATH));
|
||
|
||
// Loop through the sources available to the application to pull from
|
||
// to identify the source most likely to be holding the image
|
||
$resizeSources = static::getAvailableSources();
|
||
foreach ($resizeSources as $source => $details) {
|
||
// Normalize the source path
|
||
$sourcePath = urldecode(parse_url($details['path'], PHP_URL_PATH));
|
||
|
||
// Identify if the current source is a match
|
||
if (starts_with($relativePath, $sourcePath)) {
|
||
// Generate a path relative to the selected disk
|
||
$path = $details['folder'] . '/' . str_after($relativePath, $sourcePath . '/');
|
||
|
||
// Handle disks of type "system" (the local file system the application is running on)
|
||
if ($details['disk'] === 'system') {
|
||
Config::set('filesystems.disks.system', [
|
||
'driver' => 'local',
|
||
'root' => base_path(),
|
||
]);
|
||
// Regenerate the path relative to the newly defined "system" disk
|
||
$path = str_after($path, base_path() . '/');
|
||
}
|
||
|
||
$disk = Storage::disk($details['disk']);
|
||
|
||
// Verify that the file exists before exiting the identification process
|
||
if (!empty(pathinfo($path, PATHINFO_EXTENSION)) && $disk->exists($path)) {
|
||
break;
|
||
} else {
|
||
$disk = null;
|
||
$path = null;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!$disk || !$path) {
|
||
if (is_object($image)) {
|
||
$image = get_class($image);
|
||
}
|
||
throw new SystemException("Unable to process the provided image: " . e(var_export($image, true)));
|
||
}
|
||
|
||
return [
|
||
'disk' => $disk,
|
||
'path' => $path,
|
||
];
|
||
}
|
||
|
||
|
||
/**
|
||
* Get the reference to the resized image if the requested resize exists
|
||
*
|
||
* @param string $identifier The Resizer Identifier that references the source image and desired resizing configuration
|
||
* @return bool|string
|
||
*/
|
||
public function isResized()
|
||
{
|
||
// @Todo: need to centralize the target disk path / url generation logic
|
||
$targetPath = '/' . $this->getResizedName();
|
||
|
||
// @todo: Check if resized path exists on the target disk
|
||
}
|
||
|
||
public function resize()
|
||
{
|
||
// @TODO: Resize the image
|
||
}
|
||
|
||
/**
|
||
* Get the filename of the resized image
|
||
*/
|
||
public function getResizedName()
|
||
{
|
||
return pathinfo($this->image['path'], PATHINFO_FILENAME) . "_resized.{$this->options['extension']}";
|
||
}
|
||
|
||
/**
|
||
* Get the path of the resized image
|
||
*/
|
||
public function getResizedPath()
|
||
{
|
||
// Generate the unique file identifier for the resized image
|
||
$fileIdentifier = hash_hmac('sha1', serialize($this->getConfig()), Crypt::getKey());
|
||
|
||
return implode('/', array_slice(str_split($fileIdentifier, 10), 0, 4)) . '/';
|
||
}
|
||
|
||
/**
|
||
* Get the URL to the system resizer route for this instance's configuration
|
||
*
|
||
* @return string $url
|
||
*/
|
||
public function getResizerUrl()
|
||
{
|
||
$identifier = $this->getIdentifier();
|
||
$resizedUrl = urlencode($this->getResizedUrl());
|
||
|
||
return Url::to("/resizer/{$identifier}?t=$resizedUrl");
|
||
}
|
||
|
||
/**
|
||
* Get the URL to the resized image
|
||
*
|
||
* @return string
|
||
*/
|
||
public function getResizedUrl()
|
||
{
|
||
$resizedDisk = Storage::disk(Config::get('cms.resized.disk', 'local'));
|
||
|
||
return $resizedDisk->url(Config::get('cms.resized.folder', 'resized') . '/' . $this->getResizedPath() . $this->getResizedName());
|
||
|
||
|
||
|
||
$sources = static::getAvailableSources();
|
||
|
||
if (empty($sources[$source])) {
|
||
throw new SystemException("The provided source is invalid: " . e($source));
|
||
} else {
|
||
$sourceConfig = $sources[$source];
|
||
}
|
||
|
||
switch ($sourceConfig['target']) {
|
||
case 'alongside':
|
||
break;
|
||
default:
|
||
$path = implode('/', array_slice(str_split($hash, 10), 0, 4)) . '/' . $name;
|
||
$url = Storage::disk(config('cms.resized.disk'))->url($path);
|
||
break;
|
||
}
|
||
|
||
return $url;
|
||
}
|
||
|
||
/**
|
||
* Check if the provided identifier looks like a valid identifier
|
||
*
|
||
* @param string $id
|
||
* @return bool
|
||
*/
|
||
public static function isValidIdentifier($id)
|
||
{
|
||
return is_string($id) && ctype_alnum($id) && strlen($id) === 40;
|
||
}
|
||
|
||
/**
|
||
* Check the provided encoded URL to verify its signature and return the decoded URL
|
||
*
|
||
* @param string $identifier
|
||
* @param string $encodedUrl
|
||
* @return string|null Returns null if the provided value was invalid
|
||
*/
|
||
public static function getValidResizedUrl($identifier, $encodedUrl)
|
||
{
|
||
$url = null;
|
||
$decodedUrl = urldecode($encodedUrl);
|
||
|
||
// The identifier should be the signed version of the decoded URL
|
||
if (static::isValidIdentifier($identifier) && $identifier === hash_hmac('sha1', $decodedUrl, Crypt::getKey())) {
|
||
$url = $decodedUrl;
|
||
}
|
||
|
||
return $url;
|
||
}
|
||
|
||
public function getUrl()
|
||
{
|
||
if ($this->isResized()) {
|
||
return $this->getResizedUrl();
|
||
} else {
|
||
return $this->getResizerUrl();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Converts supplied input into a URL that will return the desired resized image
|
||
*
|
||
* @param mixed $image Supported values below:
|
||
* ['disk' => string, 'path' => string],
|
||
* instance of October\Rain\Database\Attach\File,
|
||
* string containing URL or path accessible to the application's filesystem manager
|
||
* @param integer|bool|null $width Desired width of the resized image
|
||
* @param integer|bool|null $height Desired height of the resized image
|
||
* @param array|null $options Array of options to pass to the resizer
|
||
* @throws SystemException If the provided input was unable to be processed
|
||
* @return string
|
||
*/
|
||
public static function getFilterUrl($image, $width = null, $height = null, $options = [])
|
||
{
|
||
// Attempt to process the provided image
|
||
try {
|
||
$resizer = new ImageResizer($image, $width, $height, $options);
|
||
} catch (SystemException $ex) {
|
||
// Ignore processing this URL if the resizer is unable to identify it
|
||
if (is_string($image)) {
|
||
return $image;
|
||
} elseif ($image instanceof FileModel) {
|
||
return $image->getPath();
|
||
} else {
|
||
throw $ex;
|
||
}
|
||
}
|
||
|
||
return $resizer->getUrl();
|
||
}
|
||
}
|