1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Factories; 21 22use Fig\Http\Message\StatusCodeInterface; 23use Fisharebest\Webtrees\Auth; 24use Fisharebest\Webtrees\Contracts\ImageFactoryInterface; 25use Fisharebest\Webtrees\Contracts\UserInterface; 26use Fisharebest\Webtrees\MediaFile; 27use Fisharebest\Webtrees\Mime; 28use Fisharebest\Webtrees\Registry; 29use Fisharebest\Webtrees\Webtrees; 30use Imagick; 31use Intervention\Image\Constraint; 32use Intervention\Image\Exception\NotReadableException; 33use Intervention\Image\Exception\NotSupportedException; 34use Intervention\Image\Image; 35use Intervention\Image\ImageManager; 36use League\Flysystem\FilesystemException; 37use League\Flysystem\FilesystemOperator; 38use League\Flysystem\UnableToReadFile; 39use League\Flysystem\UnableToRetrieveMetadata; 40use Psr\Http\Message\ResponseInterface; 41use RuntimeException; 42use Throwable; 43 44use function addcslashes; 45use function basename; 46use function extension_loaded; 47use function get_class; 48use function pathinfo; 49use function response; 50use function strlen; 51use function view; 52 53use const PATHINFO_EXTENSION; 54 55/** 56 * Make an image (from another image). 57 */ 58class ImageFactory implements ImageFactoryInterface 59{ 60 // Imagick can detect the quality setting for images. GD cannot. 61 protected const GD_DEFAULT_IMAGE_QUALITY = 90; 62 protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70; 63 64 protected const WATERMARK_FILE = 'resources/img/watermark.png'; 65 66 protected const THUMBNAIL_CACHE_TTL = 8640000; 67 68 protected const INTERVENTION_DRIVERS = ['imagick', 'gd']; 69 70 protected const INTERVENTION_FORMATS = [ 71 'image/jpeg' => 'jpg', 72 'image/png' => 'png', 73 'image/gif' => 'gif', 74 'image/tiff' => 'tif', 75 'image/bmp' => 'bmp', 76 'image/webp' => 'webp', 77 ]; 78 79 /** 80 * Send the original file - either inline or as a download. 81 * 82 * @param FilesystemOperator $filesystem 83 * @param string $path 84 * @param bool $download 85 * 86 * @return ResponseInterface 87 */ 88 public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface 89 { 90 try { 91 $data = $filesystem->read($path); 92 93 try { 94 $content_type = $filesystem->mimeType($path); 95 } catch (UnableToRetrieveMetadata $ex) { 96 $content_type = Mime::DEFAULT_TYPE; 97 } 98 99 $headers = [ 100 'Content-Type' => $content_type, 101 'Content-Length' => (string) strlen($data), 102 ]; 103 104 if ($download) { 105 $headers['Content-Disposition'] = 'attachment; filename="' . addcslashes(basename($path), '"'); 106 } 107 108 return response($data, StatusCodeInterface::STATUS_OK, $headers); 109 } catch (FilesystemException | UnableToReadFile $ex) { 110 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) 111 ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage()); 112 } 113 } 114 115 /** 116 * Send a thumbnail. 117 * 118 * @param FilesystemOperator $filesystem 119 * @param string $path 120 * @param int $width 121 * @param int $height 122 * @param string $fit 123 * 124 * 125 * @return ResponseInterface 126 */ 127 public function thumbnailResponse( 128 FilesystemOperator $filesystem, 129 string $path, 130 int $width, 131 int $height, 132 string $fit 133 ): ResponseInterface { 134 try { 135 $image = $this->imageManager()->make($filesystem->readStream($path)); 136 $image = $this->autorotateImage($image); 137 $image = $this->resizeImage($image, $width, $height, $fit); 138 139 $format = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg'; 140 $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY); 141 $data = (string) $image->encode($format, $quality); 142 143 return $this->imageResponse($data, $image->mime(), ''); 144 } catch (NotReadableException $ex) { 145 return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION)) 146 ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage()); 147 } catch (FilesystemException | UnableToReadFile $ex) { 148 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND); 149 } catch (Throwable $ex) { 150 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 151 ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage()); 152 } 153 } 154 155 /** 156 * Create a full-size version of an image. 157 * 158 * @param MediaFile $media_file 159 * @param bool $add_watermark 160 * @param bool $download 161 * 162 * @return ResponseInterface 163 */ 164 public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface 165 { 166 $filesystem = Registry::filesystem()->media($media_file->media()->tree()); 167 $filename = $media_file->filename(); 168 169 if (!$add_watermark || !$media_file->isImage()) { 170 return $this->fileResponse($filesystem, $filename, $download); 171 } 172 173 try { 174 $image = $this->imageManager()->make($filesystem->readStream($filename)); 175 $image = $this->autorotateImage($image); 176 177 $watermark_image = $this->createWatermark($image->width(), $image->height(), $media_file); 178 179 $image = $this->addWatermark($image, $watermark_image); 180 181 $download_filename = $download ? basename($filename) : ''; 182 183 $format = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg'; 184 $quality = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY); 185 $data = (string) $image->encode($format, $quality); 186 187 return $this->imageResponse($data, $image->mime(), $download_filename); 188 } catch (NotReadableException $ex) { 189 return $this->replacementImageResponse(pathinfo($filename, PATHINFO_EXTENSION)) 190 ->withHeader('X-Image-Exception', $ex->getMessage()); 191 } catch (FilesystemException | UnableToReadFile $ex) { 192 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND); 193 } catch (Throwable $ex) { 194 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 195 ->withHeader('X-Image-Exception', $ex->getMessage()); 196 } 197 } 198 199 /** 200 * Create a smaller version of an image. 201 * 202 * @param MediaFile $media_file 203 * @param int $width 204 * @param int $height 205 * @param string $fit 206 * @param bool $add_watermark 207 * 208 * @return ResponseInterface 209 */ 210 public function mediaFileThumbnailResponse( 211 MediaFile $media_file, 212 int $width, 213 int $height, 214 string $fit, 215 bool $add_watermark 216 ): ResponseInterface { 217 // Where are the images stored. 218 $filesystem = Registry::filesystem()->media($media_file->media()->tree()); 219 220 // Where is the image stored in the filesystem. 221 $path = $media_file->filename(); 222 223 try { 224 $mime_type = $filesystem->mimeType($path); 225 226 $key = implode(':', [ 227 $media_file->media()->tree()->name(), 228 $path, 229 $filesystem->lastModified($path), 230 (string) $width, 231 (string) $height, 232 $fit, 233 (string) $add_watermark, 234 ]); 235 236 $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string { 237 $image = $this->imageManager()->make($filesystem->readStream($path)); 238 $image = $this->autorotateImage($image); 239 $image = $this->resizeImage($image, $width, $height, $fit); 240 241 if ($add_watermark) { 242 $watermark = $this->createWatermark($image->width(), $image->height(), $media_file); 243 $image = $this->addWatermark($image, $watermark); 244 } 245 246 $format = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg'; 247 $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY); 248 249 return (string) $image->encode($format, $quality); 250 }; 251 252 // Images and Responses both contain resources - which cannot be serialized. 253 // So cache the raw image data. 254 $data = Registry::cache()->file()->remember($key, $closure, static::THUMBNAIL_CACHE_TTL); 255 256 return $this->imageResponse($data, $mime_type, ''); 257 } catch (NotReadableException $ex) { 258 return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION)) 259 ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage()); 260 } catch (FilesystemException | UnableToReadFile $ex) { 261 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND); 262 } catch (Throwable $ex) { 263 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 264 ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage()); 265 } 266 } 267 268 /** 269 * Does a full-sized image need a watermark? 270 * 271 * @param MediaFile $media_file 272 * @param UserInterface $user 273 * 274 * @return bool 275 */ 276 public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 277 { 278 $tree = $media_file->media()->tree(); 279 280 return Auth::accessLevel($tree, $user) > $tree->getPreference('SHOW_NO_WATERMARK'); 281 } 282 283 /** 284 * Does a thumbnail image need a watermark? 285 * 286 * @param MediaFile $media_file 287 * @param UserInterface $user 288 * 289 * @return bool 290 */ 291 public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 292 { 293 return $this->fileNeedsWatermark($media_file, $user); 294 } 295 296 /** 297 * Create a watermark image, perhaps specific to a media-file. 298 * 299 * @param int $width 300 * @param int $height 301 * @param MediaFile $media_file 302 * 303 * @return Image 304 */ 305 public function createWatermark(int $width, int $height, MediaFile $media_file): Image 306 { 307 return $this->imageManager() 308 ->make(Webtrees::ROOT_DIR . static::WATERMARK_FILE) 309 ->resize($width, $height, static function (Constraint $constraint) { 310 $constraint->aspectRatio(); 311 }); 312 } 313 314 /** 315 * Add a watermark to an image. 316 * 317 * @param Image $image 318 * @param Image $watermark 319 * 320 * @return Image 321 */ 322 public function addWatermark(Image $image, Image $watermark): Image 323 { 324 return $image->insert($watermark, 'center'); 325 } 326 327 /** 328 * Send a replacement image, to replace one that could not be found or created. 329 * 330 * @param string $text HTTP status code or file extension 331 * 332 * @return ResponseInterface 333 */ 334 public function replacementImageResponse(string $text): ResponseInterface 335 { 336 // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing. 337 $svg = view('errors/image-svg', ['status' => $text]); 338 339 // We can't send the actual status code, as browsers won't show images with 4xx/5xx. 340 return response($svg, StatusCodeInterface::STATUS_OK, [ 341 'Content-Type' => 'image/svg+xml', 342 ]); 343 } 344 345 /** 346 * @param string $data 347 * @param string $mime_type 348 * @param string $filename 349 * 350 * @return ResponseInterface 351 */ 352 protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface 353 { 354 $headers = [ 355 'Content-Type' => $mime_type, 356 'Content-Length' => (string) strlen($data), 357 ]; 358 359 if ($filename !== '') { 360 $headers['Content-Disposition'] = 'attachment; filename="' . addcslashes(basename($filename), '"'); 361 } 362 363 return response($data, StatusCodeInterface::STATUS_OK, $headers); 364 } 365 366 /** 367 * @return ImageManager 368 * @throws RuntimeException 369 */ 370 protected function imageManager(): ImageManager 371 { 372 foreach (static::INTERVENTION_DRIVERS as $driver) { 373 if (extension_loaded($driver)) { 374 return new ImageManager(['driver' => $driver]); 375 } 376 } 377 378 throw new RuntimeException('No PHP graphics library is installed. Need Imagick or GD'); 379 } 380 381 /** 382 * Apply EXIF rotation to an image. 383 * 384 * @param Image $image 385 * 386 * @return Image 387 */ 388 protected function autorotateImage(Image $image): Image 389 { 390 try { 391 // Auto-rotate using EXIF information. 392 return $image->orientate(); 393 } catch (NotSupportedException $ex) { 394 // If we can't auto-rotate the image, then don't. 395 return $image; 396 } 397 } 398 399 /** 400 * Resize an image. 401 * 402 * @param Image $image 403 * @param int $width 404 * @param int $height 405 * @param string $fit 406 * 407 * @return Image 408 */ 409 protected function resizeImage(Image $image, int $width, int $height, string $fit): Image 410 { 411 switch ($fit) { 412 case 'crop': 413 return $image->fit($width, $height); 414 case 'contain': 415 return $image->resize($width, $height, static function (Constraint $constraint) { 416 $constraint->aspectRatio(); 417 $constraint->upsize(); 418 }); 419 } 420 421 return $image; 422 } 423 424 /** 425 * Extract the quality/compression parameter from an image. 426 * 427 * @param Image $image 428 * @param int $default 429 * 430 * @return int 431 */ 432 protected function extractImageQuality(Image $image, int $default): int 433 { 434 $core = $image->getCore(); 435 436 if ($core instanceof Imagick) { 437 return $core->getImageCompressionQuality() ?: $default; 438 } 439 440 return $default; 441 } 442} 443