diff --git a/modules/system/classes/ImageResizer.php b/modules/system/classes/ImageResizer.php index 9f97e3d1e..665d54843 100644 --- a/modules/system/classes/ImageResizer.php +++ b/modules/system/classes/ImageResizer.php @@ -2,8 +2,10 @@ use App; use Url; +use Crypt; use Cache; use Event; +use Config; use Storage; use SystemException; use October\Rain\Database\Attach\File as FileModel; @@ -130,7 +132,7 @@ class ImageResizer * Prepare the resizer instance * * @param mixed $image Supported values below: - * ['disk' => string, 'path' => string], + * ['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 @@ -155,7 +157,7 @@ class ImageResizer public static function fromIdentifier(string $identifier) { // Attempt to retrieve the resizer configuration and remove the data from the cache after retrieval - $config = Cache::pull(static::CACHE_PREFIX . $identifier, null); + $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)) { @@ -165,6 +167,39 @@ class ImageResizer 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 * @@ -222,6 +257,17 @@ class ImageResizer } + 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 @@ -233,24 +279,17 @@ class ImageResizer */ public function getIdentifier() { - if ($this->identifer) { + if ($this->identifier) { return $this->identifier; } - // Prepare the configuration - $config = [ - 'image' => $this->image, - 'width' => $this->width, - 'height' => $this->height, - 'options' => $this->options, - ]; - // Generate the identifier - $this->identifier = hash_hmac('sha1', json_encode($config), App::make('encrypter')->getKey()); + $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()) { - Cache::put(static::CACHE_PREFIX . $this->identifier, $config); + // @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; @@ -260,28 +299,39 @@ class ImageResizer * Normalize the provided input into information that the resizer can work with * * @param mixed $image Supported values below: - * ['disk' => string, 'path' => string], + * ['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 selected source name ['disk' => string, 'path' => string, 'source' => string] + * @return array Array containing the disk, path, and extension ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string] */ public static function normalizeImage($image) { $disk = null; $path = null; - $selectedSource = 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 @@ -312,8 +362,7 @@ class ImageResizer $disk = Storage::disk($details['disk']); // Verify that the file exists before exiting the identification process - if ($disk->exists($path)) { - $selectedSource = $source; + if (!empty(pathinfo($path, PATHINFO_EXTENSION)) && $disk->exists($path)) { break; } else { $disk = null; @@ -324,14 +373,16 @@ class ImageResizer } } - if (!$disk || !$path || !$selectedSource) { - throw new SystemException("Unable to process the provided image: " . e(var_export($image))); + 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, - 'source' => $selectedSource, ]; } @@ -345,14 +396,14 @@ class ImageResizer public function isResized() { // @Todo: need to centralize the target disk path / url generation logic - $targetPath = implode('/', array_slice(str_split($identifier, 10), 0, 4)) . '/' . pathinfo($image['path'], PATHINFO_FILENAME) . "_resized_{$data['width']}_{$data['height']}.{$data['options']['extension']}"; + $targetPath = '/' . $this->getResizedName(); // @todo: Check if resized path exists on the target disk } - public function resize($image, $width = null, $height = null, $options = []) + public function resize() { - $identifier = static::getIdentifier($image, $width, $height, $options); + // @TODO: Resize the image } /** @@ -363,6 +414,17 @@ class ImageResizer 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 * @@ -371,22 +433,24 @@ class ImageResizer public function getResizerUrl() { $identifier = $this->getIdentifier(); - $resizedName = $this->getResizedName(); - $source = $this->image['source']; + $resizedUrl = urlencode($this->getResizedUrl()); - return Url::to("/resizer/$identifier/$source/$name"); + return Url::to("/resizer/{$identifier}?t=$resizedUrl"); } /** * Get the URL to the resized image * - * @param string $identifier The 40 character unique identifier for the image - * @param string $source The name of the source the image is being resized from - * @param string $name The filename of the resized image * @return string */ - public static function getResizedUrl(string $identifier, string $source, string $name) + 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])) { @@ -407,10 +471,41 @@ class ImageResizer 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 static::getResizedUrl($this->identifier, $this->image['source'], $this->getResizedName()); + return $this->getResizedUrl(); } else { return $this->getResizerUrl(); } @@ -429,7 +524,7 @@ class ImageResizer * @throws SystemException If the provided input was unable to be processed * @return string */ - public static function getFilterUrl() + public static function getFilterUrl($image, $width = null, $height = null, $options = []) { // Attempt to process the provided image try { @@ -441,7 +536,7 @@ class ImageResizer } elseif ($image instanceof FileModel) { return $image->getPath(); } else { - throw new SystemException("Unable to process the provided image: " . e(var_export($image))); + throw $ex; } } diff --git a/modules/system/classes/SystemController.php b/modules/system/classes/SystemController.php index e172d7b94..205743e65 100644 --- a/modules/system/classes/SystemController.php +++ b/modules/system/classes/SystemController.php @@ -46,21 +46,28 @@ class SystemController extends ControllerBase * and returns a redirect to the resized image * * @param string $identifier The identifier used to retrieve the image configuration - * @param string $source The name of the source the image is being resized from - * @param string $name The filename of the resized image * @return RedirectResponse */ - public function resize(string $identifier, string $source, string $name) + public function resizer(string $identifier) { + // The URL-encoded URL to the resized image has to be passed as a GET variable + // because of Laravel rawurldecode's the requested URL before passing it to the routing engine + // @see https://github.com/laravel/framework/pull/4338 + $encodedUrl = input('t'); + + $resizedUrl = ImageResizer::getValidResizedUrl($identifier, $encodedUrl); + if (empty($resizedUrl)) { + return response('Invalid identifier or redirect URL', 400); + } + // Attempt to process the resize try { $resizer = ImageResizer::fromIdentifier($identifier); $resizer->resize(); } catch (SystemException $ex) { - // If the resizing failed it was most likely because the config had already been - // pulled from the cache so just continue to redirect the user to the final file anyways + // If the resizing failed it was most likely because it is in progress or has already finished } - return redirect()->to(ImageResizer::getResizedUrl($identifier, $source, $name)); + return redirect()->to($resizedUrl); } }