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