1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 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 implode; 49use function pathinfo; 50use function response; 51use function str_contains; 52use function view; 53 54use const PATHINFO_EXTENSION; 55 56/** 57 * Make an image (from another image). 58 */ 59class ImageFactory implements ImageFactoryInterface 60{ 61 // Imagick can detect the quality setting for images. GD cannot. 62 protected const GD_DEFAULT_IMAGE_QUALITY = 90; 63 protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70; 64 65 protected const WATERMARK_FILE = 'resources/img/watermark.png'; 66 67 protected const THUMBNAIL_CACHE_TTL = 8640000; 68 69 protected const INTERVENTION_DRIVERS = ['imagick', 'gd']; 70 71 public const SUPPORTED_FORMATS = [ 72 'image/jpeg' => 'jpg', 73 'image/png' => 'png', 74 'image/gif' => 'gif', 75 'image/tiff' => 'tif', 76 'image/bmp' => 'bmp', 77 'image/webp' => 'webp', 78 ]; 79 80 /** 81 * Send the original file - either inline or as a download. 82 * 83 * @param FilesystemOperator $filesystem 84 * @param string $path 85 * @param bool $download 86 * 87 * @return ResponseInterface 88 */ 89 public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface 90 { 91 try { 92 try { 93 $mime_type = $filesystem->mimeType($path); 94 } catch (UnableToRetrieveMetadata) { 95 $mime_type = Mime::DEFAULT_TYPE; 96 } 97 98 $filename = $download ? addcslashes(basename($path), '"') : ''; 99 100 return $this->imageResponse($filesystem->read($path), $mime_type, $filename); 101 } catch (UnableToReadFile | FilesystemException $ex) { 102 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) 103 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 104 } 105 } 106 107 /** 108 * Send a thumbnail. 109 * 110 * @param FilesystemOperator $filesystem 111 * @param string $path 112 * @param int $width 113 * @param int $height 114 * @param string $fit 115 * 116 * 117 * @return ResponseInterface 118 */ 119 public function thumbnailResponse( 120 FilesystemOperator $filesystem, 121 string $path, 122 int $width, 123 int $height, 124 string $fit 125 ): ResponseInterface { 126 try { 127 $image = $this->imageManager()->make($filesystem->readStream($path)); 128 $image = $this->autorotateImage($image); 129 $image = $this->resizeImage($image, $width, $height, $fit); 130 131 $format = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg'; 132 $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY); 133 $data = (string) $image->encode($format, $quality); 134 135 return $this->imageResponse($data, $image->mime(), ''); 136 } catch (NotReadableException $ex) { 137 return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION)) 138 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 139 } catch (FilesystemException | UnableToReadFile $ex) { 140 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) 141 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 142 } catch (Throwable $ex) { 143 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 144 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 145 } 146 } 147 148 /** 149 * Create a full-size version of an image. 150 * 151 * @param MediaFile $media_file 152 * @param bool $add_watermark 153 * @param bool $download 154 * 155 * @return ResponseInterface 156 */ 157 public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface 158 { 159 $filesystem = $media_file->media()->tree()->mediaFilesystem(); 160 $path = $media_file->filename(); 161 162 if (!$add_watermark || !$media_file->isImage()) { 163 return $this->fileResponse($filesystem, $path, $download); 164 } 165 166 try { 167 $image = $this->imageManager()->make($filesystem->readStream($path)); 168 $image = $this->autorotateImage($image); 169 $watermark = $this->createWatermark($image->width(), $image->height(), $media_file); 170 $image = $this->addWatermark($image, $watermark); 171 $filename = $download ? basename($path) : ''; 172 $format = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg'; 173 $quality = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY); 174 $data = (string) $image->encode($format, $quality); 175 176 return $this->imageResponse($data, $image->mime(), $filename); 177 } catch (NotReadableException $ex) { 178 return $this->replacementImageResponse(pathinfo($path, PATHINFO_EXTENSION)) 179 ->withHeader('x-image-exception', $ex->getMessage()); 180 } catch (FilesystemException | UnableToReadFile $ex) { 181 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) 182 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 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 = $media_file->media()->tree()->mediaFilesystem(); 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::SUPPORTED_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 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 250 } catch (FilesystemException | UnableToReadFile $ex) { 251 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND) 252 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 253 } catch (Throwable $ex) { 254 return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 255 ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage()); 256 } 257 } 258 259 /** 260 * Does a full-sized image need a watermark? 261 * 262 * @param MediaFile $media_file 263 * @param UserInterface $user 264 * 265 * @return bool 266 */ 267 public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 268 { 269 $tree = $media_file->media()->tree(); 270 271 return Auth::accessLevel($tree, $user) > (int) $tree->getPreference('SHOW_NO_WATERMARK'); 272 } 273 274 /** 275 * Does a thumbnail image need a watermark? 276 * 277 * @param MediaFile $media_file 278 * @param UserInterface $user 279 * 280 * @return bool 281 */ 282 public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 283 { 284 return $this->fileNeedsWatermark($media_file, $user); 285 } 286 287 /** 288 * Create a watermark image, perhaps specific to a media-file. 289 * 290 * @param int $width 291 * @param int $height 292 * @param MediaFile $media_file 293 * 294 * @return Image 295 */ 296 public function createWatermark(int $width, int $height, MediaFile $media_file): Image 297 { 298 return $this->imageManager() 299 ->make(Webtrees::ROOT_DIR . static::WATERMARK_FILE) 300 ->resize($width, $height, static function (Constraint $constraint) { 301 $constraint->aspectRatio(); 302 }); 303 } 304 305 /** 306 * Add a watermark to an image. 307 * 308 * @param Image $image 309 * @param Image $watermark 310 * 311 * @return Image 312 */ 313 public function addWatermark(Image $image, Image $watermark): Image 314 { 315 return $image->insert($watermark, 'center'); 316 } 317 318 /** 319 * Send a replacement image, to replace one that could not be found or created. 320 * 321 * @param string $text HTTP status code or file extension 322 * 323 * @return ResponseInterface 324 */ 325 public function replacementImageResponse(string $text): ResponseInterface 326 { 327 // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing. 328 $svg = view('errors/image-svg', ['status' => $text]); 329 330 // We can't send the actual status code, as browsers won't show images with 4xx/5xx. 331 return response($svg, StatusCodeInterface::STATUS_OK, [ 332 'content-type' => 'image/svg+xml', 333 ]); 334 } 335 336 /** 337 * @param string $data 338 * @param string $mime_type 339 * @param string $filename 340 * 341 * @return ResponseInterface 342 */ 343 protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface 344 { 345 if ($mime_type === 'image/svg+xml' && str_contains($data, '<script')) { 346 return $this->replacementImageResponse('XSS') 347 ->withHeader('x-image-exception', 'SVG image blocked due to XSS.'); 348 } 349 350 // HTML files may contain javascript and iframes, so use content-security-policy to disable them. 351 $response = response($data) 352 ->withHeader('content-type', $mime_type) 353 ->withHeader('content-security-policy', 'script-src none;frame-src none'); 354 355 if ($filename === '') { 356 return $response; 357 } 358 359 return $response 360 ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(basename($filename), '"')); 361 } 362 363 /** 364 * @return ImageManager 365 * @throws RuntimeException 366 */ 367 protected function imageManager(): ImageManager 368 { 369 foreach (static::INTERVENTION_DRIVERS as $driver) { 370 if (extension_loaded($driver)) { 371 return new ImageManager(['driver' => $driver]); 372 } 373 } 374 375 throw new RuntimeException('No PHP graphics library is installed. Need Imagick or GD'); 376 } 377 378 /** 379 * Apply EXIF rotation to an image. 380 * 381 * @param Image $image 382 * 383 * @return Image 384 */ 385 protected function autorotateImage(Image $image): Image 386 { 387 try { 388 // Auto-rotate using EXIF information. 389 return $image->orientate(); 390 } catch (NotSupportedException) { 391 // If we can't auto-rotate the image, then don't. 392 return $image; 393 } 394 } 395 396 /** 397 * Resize an image. 398 * 399 * @param Image $image 400 * @param int $width 401 * @param int $height 402 * @param string $fit 403 * 404 * @return Image 405 */ 406 protected function resizeImage(Image $image, int $width, int $height, string $fit): Image 407 { 408 switch ($fit) { 409 case 'crop': 410 return $image->fit($width, $height); 411 case 'contain': 412 return $image->resize($width, $height, static function (Constraint $constraint) { 413 $constraint->aspectRatio(); 414 $constraint->upsize(); 415 }); 416 } 417 418 return $image; 419 } 420 421 /** 422 * Extract the quality/compression parameter from an image. 423 * 424 * @param Image $image 425 * @param int $default 426 * 427 * @return int 428 */ 429 protected function extractImageQuality(Image $image, int $default): int 430 { 431 $core = $image->getCore(); 432 433 if ($core instanceof Imagick) { 434 return $core->getImageCompressionQuality() ?: $default; 435 } 436 437 return $default; 438 } 439} 440