. */ declare(strict_types=1); namespace Fisharebest\Webtrees\Factories; use Fig\Http\Message\StatusCodeInterface; use Fisharebest\Webtrees\Auth; use Fisharebest\Webtrees\Contracts\ImageFactoryInterface; use Fisharebest\Webtrees\Contracts\UserInterface; use Fisharebest\Webtrees\MediaFile; 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\Image\ImageManager; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToRetrieveMetadata; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Throwable; use function addcslashes; use function basename; use function extension_loaded; use function get_class; use function implode; use function pathinfo; use function response; use function str_contains; use function view; use const PATHINFO_EXTENSION; /** * Make an image (from another image). */ class ImageFactory implements ImageFactoryInterface { // Imagick can detect the quality setting for images. GD cannot. protected const GD_DEFAULT_IMAGE_QUALITY = 90; protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70; protected const WATERMARK_FILE = 'resources/img/watermark.png'; protected const THUMBNAIL_CACHE_TTL = 8640000; protected const INTERVENTION_DRIVERS = ['imagick', 'gd']; protected const INTERVENTION_FORMATS = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/tiff' => 'tif', 'image/bmp' => 'bmp', 'image/webp' => 'webp', ]; /** * 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); } catch (UnableToRetrieveMetadata $ex) { $mime_type = Mime::DEFAULT_TYPE; } $filename = $download ? addcslashes(basename($path), '"') : ''; return $this->imageResponse($filesystem->read($path), $mime_type, $filename); } catch (UnableToReadFile | FilesystemException $ex) { return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND); } } /** * 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, string $path, int $width, int $height, string $fit ): ResponseInterface { try { $image = $this->imageManager()->make($filesystem->readStream($path)); $image = $this->autorotateImage($image); $image = $this->resizeImage($image, $width, $height, $fit); $format = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg'; $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY); $data = (string) $image->encode($format, $quality); 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()); } catch (FilesystemException | UnableToReadFile $ex) { return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND); } catch (Throwable $ex) { return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) ->withHeader('x-thumbnail-exception', get_class($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 { $filesystem = Registry::filesystem()->media($media_file->media()->tree()); $path = $media_file->filename(); if (!$add_watermark || !$media_file->isImage()) { return $this->fileResponse($filesystem, $path, $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::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg'; $quality = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY); $data = (string) $image->encode($format, $quality); return $this->imageResponse($data, $image->mime(), $filename); } catch (NotReadableException $ex) { return $this->replacementImageResponse(pathinfo($path, PATHINFO_EXTENSION)) ->withHeader('x-image-exception', $ex->getMessage()); } catch (FilesystemException | UnableToReadFile $ex) { return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND); } catch (Throwable $ex) { return $this->replacementImageResponse((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, int $width, int $height, string $fit, bool $add_watermark ): ResponseInterface { // Where are the images stored. $filesystem = Registry::filesystem()->media($media_file->media()->tree()); // Where is the image stored in the filesystem. $path = $media_file->filename(); try { $mime_type = $filesystem->mimeType($path); $key = implode(':', [ $media_file->media()->tree()->name(), $path, $filesystem->lastModified($path), (string) $width, (string) $height, $fit, (string) $add_watermark, ]); $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); if ($add_watermark) { $watermark = $this->createWatermark($image->width(), $image->height(), $media_file); $image = $this->addWatermark($image, $watermark); } $format = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg'; $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY); return (string) $image->encode($format, $quality); }; // 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); return $this->imageResponse($data, $mime_type, ''); } catch (NotReadableException $ex) { return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION)) ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); } catch (FilesystemException | UnableToReadFile $ex) { return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND); } catch (Throwable $ex) { return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) ->withHeader('x-thumbnail-exception', get_class($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'); } /** * 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); } /** * 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 { return $this->imageManager() ->make(Webtrees::ROOT_DIR . static::WATERMARK_FILE) ->resize($width, $height, static function (Constraint $constraint) { $constraint->aspectRatio(); }); } /** * Add a watermark to an image. * * @param Image $image * @param Image $watermark * * @return Image */ public function addWatermark(Image $image, Image $watermark): Image { return $image->insert($watermark, '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]); // We can't send the actual status code, as browsers won't show images with 4xx/5xx. return response($svg, StatusCodeInterface::STATUS_OK, [ 'content-type' => 'image/svg+xml', ]); } /** * @param string $data * @param string $mime_type * @param string $filename * * @return ResponseInterface */ protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface { if ($mime_type === 'image/svg+xml' && str_contains($data, 'replacementImageResponse('XSS') ->withHeader('x-image-exception', 'SVG image blocked due to XSS.'); } // HTML files may contain javascript and iframes, so use content-security-policy to disable them. $response = response($data) ->withHeader('content-type', $mime_type) ->withHeader('content-security-policy', 'script-src none;frame-src none'); if ($filename === '') { return $response; } return $response ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(basename($filename), '"')); } /** * @return ImageManager * @throws RuntimeException */ protected function imageManager(): ImageManager { foreach (static::INTERVENTION_DRIVERS as $driver) { if (extension_loaded($driver)) { return new ImageManager(['driver' => $driver]); } } 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 $ex) { // If we can't auto-rotate the image, then don't. return $image; } } /** * 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 { 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; } /** * Extract the quality/compression parameter from an image. * * @param Image $image * @param int $default * * @return int */ protected function extractImageQuality(Image $image, int $default): int { $core = $image->getCore(); if ($core instanceof Imagick) { return $core->getImageCompressionQuality() ?: $default; } return $default; } }