diff options
| author | Greg Roach <greg@subaqua.co.uk> | 2024-04-09 21:50:56 +0100 |
|---|---|---|
| committer | Greg Roach <greg@subaqua.co.uk> | 2024-04-09 21:50:56 +0100 |
| commit | 06c3e14e4fed5ad862e3566855102053125b09c6 (patch) | |
| tree | 276175b39feb4b8eb4f0936e9d0f4fe592c8f5e0 /app/Factories | |
| parent | ee0ab980f939369a24d017bc3d141cbe752b77fe (diff) | |
| download | webtrees-06c3e14e4fed5ad862e3566855102053125b09c6.tar.gz webtrees-06c3e14e4fed5ad862e3566855102053125b09c6.tar.bz2 webtrees-06c3e14e4fed5ad862e3566855102053125b09c6.zip | |
Fix: #4592 - apply EXIF rotation to images
Diffstat (limited to 'app/Factories')
| -rw-r--r-- | app/Factories/ImageFactory.php | 273 |
1 files changed, 89 insertions, 184 deletions
diff --git a/app/Factories/ImageFactory.php b/app/Factories/ImageFactory.php index d28750c48f..a11b7c874b 100644 --- a/app/Factories/ImageFactory.php +++ b/app/Factories/ImageFactory.php @@ -28,11 +28,12 @@ use Fisharebest\Webtrees\Mime; use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Webtrees; use Imagick; -use Intervention\Image\Constraint; -use Intervention\Image\Exception\NotReadableException; -use Intervention\Image\Exception\NotSupportedException; -use Intervention\Image\Image; +use Intervention\Gif\Exceptions\NotReadableException; +use Intervention\Image\Drivers\Gd\Driver as GdDriver; +use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; use Intervention\Image\ImageManager; +use Intervention\Image\Interfaces\ImageInterface; +use InvalidArgumentException; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; use League\Flysystem\UnableToReadFile; @@ -66,8 +67,6 @@ class ImageFactory implements ImageFactoryInterface protected const THUMBNAIL_CACHE_TTL = 8640000; - protected const INTERVENTION_DRIVERS = ['imagick', 'gd']; - public const SUPPORTED_FORMATS = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', @@ -79,42 +78,27 @@ class ImageFactory implements ImageFactoryInterface /** * Send the original file - either inline or as a download. - * - * @param FilesystemOperator $filesystem - * @param string $path - * @param bool $download - * - * @return ResponseInterface */ public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface { try { try { - $mime_type = $filesystem->mimeType($path); + $mime_type = $filesystem->mimeType(path: $path); } catch (UnableToRetrieveMetadata) { $mime_type = Mime::DEFAULT_TYPE; } - $filename = $download ? addcslashes(basename($path), '"') : ''; + $filename = $download ? addcslashes(string: basename(path: $path), characters: '"') : ''; - return $this->imageResponse($filesystem->read($path), $mime_type, $filename); + return $this->imageResponse(data: $filesystem->read(location: $path), mime_type: $mime_type, filename: $filename); } catch (UnableToReadFile | FilesystemException $ex) { - return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } } /** * Send a thumbnail. - * - * @param FilesystemOperator $filesystem - * @param string $path - * @param int $width - * @param int $height - * @param string $fit - * - * - * @return ResponseInterface */ public function thumbnailResponse( FilesystemOperator $filesystem, @@ -124,35 +108,30 @@ class ImageFactory implements ImageFactoryInterface string $fit ): ResponseInterface { try { - $image = $this->imageManager()->make($filesystem->readStream($path)); - $image = $this->autorotateImage($image); - $image = $this->resizeImage($image, $width, $height, $fit); - - $format = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg'; - $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY); - $data = (string) $image->encode($format, $quality); + $mime_type = $filesystem->mimeType(path: $path); + $image = $this->imageManager()->read(input: $filesystem->readStream($path)); + $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); + $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); + $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); - return $this->imageResponse($data, $image->mime(), ''); - } catch (NotReadableException $ex) { - return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION)) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); } catch (FilesystemException | UnableToReadFile $ex) { - return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this + ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); + } catch (RuntimeException $ex) { + return $this + ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (Throwable $ex) { - return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this + ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } } /** * Create a full-size version of an image. - * - * @param MediaFile $media_file - * @param bool $add_watermark - * @param bool $download - * - * @return ResponseInterface */ public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface { @@ -160,42 +139,33 @@ class ImageFactory implements ImageFactoryInterface $path = $media_file->filename(); if (!$add_watermark || !$media_file->isImage()) { - return $this->fileResponse($filesystem, $path, $download); + return $this->fileResponse(filesystem: $filesystem, path: $path, download: $download); } try { - $image = $this->imageManager()->make($filesystem->readStream($path)); - $image = $this->autorotateImage($image); - $watermark = $this->createWatermark($image->width(), $image->height(), $media_file); - $image = $this->addWatermark($image, $watermark); - $filename = $download ? basename($path) : ''; - $format = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg'; - $quality = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY); - $data = (string) $image->encode($format, $quality); + $mime_type = $media_file->mimeType(); + $image = $this->imageManager()->read(input: $filesystem->readStream($path)); + $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); + $image = $this->addWatermark(image: $image, watermark: $watermark); + $filename = $download ? basename(path: $path) : ''; + $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_IMAGE_QUALITY); + $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); - return $this->imageResponse($data, $image->mime(), $filename); + return $this->imageResponse(data: $data, mime_type: $mime_type, filename: $filename); } catch (NotReadableException $ex) { - return $this->replacementImageResponse(pathinfo($path, PATHINFO_EXTENSION)) + return $this->replacementImageResponse(text: pathinfo(path: $path, flags: PATHINFO_EXTENSION)) ->withHeader('x-image-exception', $ex->getMessage()); } catch (FilesystemException | UnableToReadFile $ex) { - return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (Throwable $ex) { - return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) + return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) ->withHeader('x-image-exception', $ex->getMessage()); } } /** * Create a smaller version of an image. - * - * @param MediaFile $media_file - * @param int $width - * @param int $height - * @param string $fit - * @param bool $add_watermark - * - * @return ResponseInterface */ public function mediaFileThumbnailResponse( MediaFile $media_file, @@ -211,12 +181,12 @@ class ImageFactory implements ImageFactoryInterface $path = $media_file->filename(); try { - $mime_type = $filesystem->mimeType($path); + $mime_type = $filesystem->mimeType(path: $path); - $key = implode(':', [ + $key = implode(separator: ':', array: [ $media_file->media()->tree()->name(), $path, - $filesystem->lastModified($path), + $filesystem->lastModified(path: $path), (string) $width, (string) $height, $fit, @@ -224,126 +194,96 @@ class ImageFactory implements ImageFactoryInterface ]); $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string { - $image = $this->imageManager()->make($filesystem->readStream($path)); - $image = $this->autorotateImage($image); - $image = $this->resizeImage($image, $width, $height, $fit); + $image = $this->imageManager()->read(input: $filesystem->readStream($path)); + $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); if ($add_watermark) { - $watermark = $this->createWatermark($image->width(), $image->height(), $media_file); - $image = $this->addWatermark($image, $watermark); + $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); + $image = $this->addWatermark(image: $image, watermark: $watermark); } - $format = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg'; - $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY); + $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); - return (string) $image->encode($format, $quality); + return $image->encodeByMediaType(type: $media_file->mimeType(), quality: $quality)->toString(); }; // Images and Responses both contain resources - which cannot be serialized. // So cache the raw image data. - $data = Registry::cache()->file()->remember($key, $closure, static::THUMBNAIL_CACHE_TTL); + $data = Registry::cache()->file()->remember(key: $key, closure: $closure, ttl: static::THUMBNAIL_CACHE_TTL); - return $this->imageResponse($data, $mime_type, ''); + return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); } catch (NotReadableException $ex) { - return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION)) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this + ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (FilesystemException | UnableToReadFile $ex) { - return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this + ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (Throwable $ex) { - return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) - ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); + return $this + ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) + ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } } /** * Does a full-sized image need a watermark? - * - * @param MediaFile $media_file - * @param UserInterface $user - * - * @return bool */ public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool { $tree = $media_file->media()->tree(); - return Auth::accessLevel($tree, $user) > (int) $tree->getPreference('SHOW_NO_WATERMARK'); + return Auth::accessLevel(tree: $tree, user: $user) > (int) $tree->getPreference(setting_name: 'SHOW_NO_WATERMARK'); } /** * Does a thumbnail image need a watermark? - * - * @param MediaFile $media_file - * @param UserInterface $user - * - * @return bool */ public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool { - return $this->fileNeedsWatermark($media_file, $user); + return $this->fileNeedsWatermark(media_file: $media_file, user: $user); } /** * Create a watermark image, perhaps specific to a media-file. - * - * @param int $width - * @param int $height - * @param MediaFile $media_file - * - * @return Image */ - public function createWatermark(int $width, int $height, MediaFile $media_file): Image + public function createWatermark(int $width, int $height, MediaFile $media_file): ImageInterface { return $this->imageManager() - ->make(Webtrees::ROOT_DIR . static::WATERMARK_FILE) - ->resize($width, $height, static function (Constraint $constraint) { - $constraint->aspectRatio(); - }); + ->read(input: Webtrees::ROOT_DIR . static::WATERMARK_FILE) + ->contain(width: $width, height: $height); } /** * Add a watermark to an image. - * - * @param Image $image - * @param Image $watermark - * - * @return Image */ - public function addWatermark(Image $image, Image $watermark): Image + public function addWatermark(ImageInterface $image, ImageInterface $watermark): ImageInterface { - return $image->insert($watermark, 'center'); + return $image->place(element: $watermark, position: 'center'); } /** * Send a replacement image, to replace one that could not be found or created. - * - * @param string $text HTTP status code or file extension - * - * @return ResponseInterface */ public function replacementImageResponse(string $text): ResponseInterface { // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing. - $svg = view('errors/image-svg', ['status' => $text]); + $svg = view(name: 'errors/image-svg', data: ['status' => $text]); // We can't send the actual status code, as browsers won't show images with 4xx/5xx. - return response($svg, StatusCodeInterface::STATUS_OK, [ + return response(content: $svg, code: StatusCodeInterface::STATUS_OK, headers: [ 'content-type' => 'image/svg+xml', ]); } /** - * @param string $data - * @param string $mime_type - * @param string $filename - * - * @return ResponseInterface + * Create a response from image data. */ protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface { - if ($mime_type === 'image/svg+xml' && str_contains($data, '<script')) { - return $this->replacementImageResponse('XSS') + if ($mime_type === 'image/svg+xml' && str_contains(haystack: $data, needle: '<script')) { + return $this->replacementImageResponse(text: 'XSS') ->withHeader('x-image-exception', 'SVG image blocked due to XSS.'); } @@ -357,81 +297,46 @@ class ImageFactory implements ImageFactoryInterface } return $response - ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(basename($filename), '"')); + ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(string: basename(path: $filename), characters: '"')); } /** - * @return ImageManager - * @throws RuntimeException + * Choose an image library, based on what is installed. */ protected function imageManager(): ImageManager { - foreach (static::INTERVENTION_DRIVERS as $driver) { - if (extension_loaded($driver)) { - return new ImageManager(['driver' => $driver]); - } + if (extension_loaded(extension: 'imagick')) { + return new ImageManager(driver: new ImagickDriver()); } - throw new RuntimeException('No PHP graphics library is installed. Need Imagick or GD'); - } - - /** - * Apply EXIF rotation to an image. - * - * @param Image $image - * - * @return Image - */ - protected function autorotateImage(Image $image): Image - { - try { - // Auto-rotate using EXIF information. - return $image->orientate(); - } catch (NotSupportedException) { - // If we can't auto-rotate the image, then don't. - return $image; + if (extension_loaded(extension: 'gd')) { + return new ImageManager(driver: new GdDriver()); } + + throw new RuntimeException(message: 'No PHP graphics library is installed. Need Imagick or GD'); } /** * Resize an image. - * - * @param Image $image - * @param int $width - * @param int $height - * @param string $fit - * - * @return Image */ - protected function resizeImage(Image $image, int $width, int $height, string $fit): Image + protected function resizeImage(ImageInterface $image, int $width, int $height, string $fit): ImageInterface { - switch ($fit) { - case 'crop': - return $image->fit($width, $height); - case 'contain': - return $image->resize($width, $height, static function (Constraint $constraint) { - $constraint->aspectRatio(); - $constraint->upsize(); - }); - } - - return $image; + return match ($fit) { + 'crop' => $image->cover(width: $width, height: $height), + 'contain' => $image->scale(width: $width, height: $height), + default => throw new InvalidArgumentException(message: 'Unknown fit type: ' . $fit), + }; } /** * Extract the quality/compression parameter from an image. - * - * @param Image $image - * @param int $default - * - * @return int */ - protected function extractImageQuality(Image $image, int $default): int + protected function extractImageQuality(ImageInterface $image, int $default): int { - $core = $image->getCore(); + $native = $image->core()->native(); - if ($core instanceof Imagick) { - return $core->getImageCompressionQuality() ?: $default; + if ($native instanceof Imagick) { + return $native->getImageCompressionQuality(); } return $default; |
