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