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