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\Gif\Exceptions\NotReadableException; 32use Intervention\Image\Drivers\Gd\Driver as GdDriver; 33use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; 34use Intervention\Image\ImageManager; 35use Intervention\Image\Interfaces\ImageInterface; 36use InvalidArgumentException; 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 public const SUPPORTED_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 public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface 83 { 84 try { 85 try { 86 $mime_type = $filesystem->mimeType(path: $path); 87 } catch (UnableToRetrieveMetadata) { 88 $mime_type = Mime::DEFAULT_TYPE; 89 } 90 91 $filename = $download ? addcslashes(string: basename(path: $path), characters: '"') : ''; 92 93 return $this->imageResponse(data: $filesystem->read(location: $path), mime_type: $mime_type, filename: $filename); 94 } catch (UnableToReadFile | FilesystemException $ex) { 95 return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 96 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 97 } 98 } 99 100 /** 101 * Send a thumbnail. 102 */ 103 public function thumbnailResponse( 104 FilesystemOperator $filesystem, 105 string $path, 106 int $width, 107 int $height, 108 string $fit 109 ): ResponseInterface { 110 try { 111 $mime_type = $filesystem->mimeType(path: $path); 112 $image = $this->imageManager()->read(input: $filesystem->readStream($path)); 113 $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); 114 $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); 115 $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); 116 117 return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); 118 } catch (FilesystemException | UnableToReadFile $ex) { 119 return $this 120 ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 121 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 122 } catch (RuntimeException $ex) { 123 return $this 124 ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) 125 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 126 } catch (Throwable $ex) { 127 return $this 128 ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 129 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 130 } 131 } 132 133 /** 134 * Create a full-size version of an image. 135 */ 136 public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface 137 { 138 $filesystem = $media_file->media()->tree()->mediaFilesystem(); 139 $path = $media_file->filename(); 140 141 if (!$add_watermark || !$media_file->isImage()) { 142 return $this->fileResponse(filesystem: $filesystem, path: $path, download: $download); 143 } 144 145 try { 146 $mime_type = $media_file->mimeType(); 147 $image = $this->imageManager()->read(input: $filesystem->readStream($path)); 148 $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); 149 $image = $this->addWatermark(image: $image, watermark: $watermark); 150 $filename = $download ? basename(path: $path) : ''; 151 $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_IMAGE_QUALITY); 152 $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); 153 154 return $this->imageResponse(data: $data, mime_type: $mime_type, filename: $filename); 155 } catch (NotReadableException $ex) { 156 return $this->replacementImageResponse(text: pathinfo(path: $path, flags: PATHINFO_EXTENSION)) 157 ->withHeader('x-image-exception', $ex->getMessage()); 158 } catch (FilesystemException | UnableToReadFile $ex) { 159 return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 160 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 161 } catch (Throwable $ex) { 162 return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 163 ->withHeader('x-image-exception', $ex->getMessage()); 164 } 165 } 166 167 /** 168 * Create a smaller version of an image. 169 */ 170 public function mediaFileThumbnailResponse( 171 MediaFile $media_file, 172 int $width, 173 int $height, 174 string $fit, 175 bool $add_watermark 176 ): ResponseInterface { 177 // Where are the images stored. 178 $filesystem = $media_file->media()->tree()->mediaFilesystem(); 179 180 // Where is the image stored in the filesystem. 181 $path = $media_file->filename(); 182 183 try { 184 $mime_type = $filesystem->mimeType(path: $path); 185 186 $key = implode(separator: ':', array: [ 187 $media_file->media()->tree()->name(), 188 $path, 189 $filesystem->lastModified(path: $path), 190 (string) $width, 191 (string) $height, 192 $fit, 193 (string) $add_watermark, 194 ]); 195 196 $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string { 197 $image = $this->imageManager()->read(input: $filesystem->readStream($path)); 198 $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); 199 200 if ($add_watermark) { 201 $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); 202 $image = $this->addWatermark(image: $image, watermark: $watermark); 203 } 204 205 $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); 206 207 return $image->encodeByMediaType(type: $media_file->mimeType(), quality: $quality)->toString(); 208 }; 209 210 // Images and Responses both contain resources - which cannot be serialized. 211 // So cache the raw image data. 212 $data = Registry::cache()->file()->remember(key: $key, closure: $closure, ttl: static::THUMBNAIL_CACHE_TTL); 213 214 return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); 215 } catch (NotReadableException $ex) { 216 return $this 217 ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) 218 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 219 } catch (FilesystemException | UnableToReadFile $ex) { 220 return $this 221 ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 222 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 223 } catch (Throwable $ex) { 224 return $this 225 ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 226 ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 227 } 228 } 229 230 /** 231 * Does a full-sized image need a watermark? 232 */ 233 public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 234 { 235 $tree = $media_file->media()->tree(); 236 237 return Auth::accessLevel(tree: $tree, user: $user) > (int) $tree->getPreference(setting_name: 'SHOW_NO_WATERMARK'); 238 } 239 240 /** 241 * Does a thumbnail image need a watermark? 242 */ 243 public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 244 { 245 return $this->fileNeedsWatermark(media_file: $media_file, user: $user); 246 } 247 248 /** 249 * Create a watermark image, perhaps specific to a media-file. 250 */ 251 public function createWatermark(int $width, int $height, MediaFile $media_file): ImageInterface 252 { 253 return $this->imageManager() 254 ->read(input: Webtrees::ROOT_DIR . static::WATERMARK_FILE) 255 ->contain(width: $width, height: $height); 256 } 257 258 /** 259 * Add a watermark to an image. 260 */ 261 public function addWatermark(ImageInterface $image, ImageInterface $watermark): ImageInterface 262 { 263 return $image->place(element: $watermark, position: 'center'); 264 } 265 266 /** 267 * Send a replacement image, to replace one that could not be found or created. 268 */ 269 public function replacementImageResponse(string $text): ResponseInterface 270 { 271 // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing. 272 $svg = view(name: 'errors/image-svg', data: ['status' => $text]); 273 274 // We can't send the actual status code, as browsers won't show images with 4xx/5xx. 275 return response(content: $svg, code: StatusCodeInterface::STATUS_OK, headers: [ 276 'content-type' => 'image/svg+xml', 277 ]); 278 } 279 280 /** 281 * Create a response from image data. 282 */ 283 protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface 284 { 285 if ($mime_type === 'image/svg+xml' && str_contains(haystack: $data, needle: '<script')) { 286 return $this->replacementImageResponse(text: 'XSS') 287 ->withHeader('x-image-exception', 'SVG image blocked due to XSS.'); 288 } 289 290 // HTML files may contain javascript and iframes, so use content-security-policy to disable them. 291 $response = response($data) 292 ->withHeader('content-type', $mime_type) 293 ->withHeader('content-security-policy', 'script-src none;frame-src none'); 294 295 if ($filename === '') { 296 return $response; 297 } 298 299 return $response 300 ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(string: basename(path: $filename), characters: '"')); 301 } 302 303 /** 304 * Choose an image library, based on what is installed. 305 */ 306 protected function imageManager(): ImageManager 307 { 308 if (extension_loaded(extension: 'imagick')) { 309 return new ImageManager(driver: new ImagickDriver()); 310 } 311 312 if (extension_loaded(extension: 'gd')) { 313 return new ImageManager(driver: new GdDriver()); 314 } 315 316 throw new RuntimeException(message: 'No PHP graphics library is installed. Need Imagick or GD'); 317 } 318 319 /** 320 * Resize an image. 321 */ 322 protected function resizeImage(ImageInterface $image, int $width, int $height, string $fit): ImageInterface 323 { 324 return match ($fit) { 325 'crop' => $image->cover(width: $width, height: $height), 326 'contain' => $image->scale(width: $width, height: $height), 327 default => throw new InvalidArgumentException(message: 'Unknown fit type: ' . $fit), 328 }; 329 } 330 331 /** 332 * Extract the quality/compression parameter from an image. 333 */ 334 protected function extractImageQuality(ImageInterface $image, int $default): int 335 { 336 $native = $image->core()->native(); 337 338 if ($native instanceof Imagick) { 339 return $native->getImageCompressionQuality(); 340 } 341 342 return $default; 343 } 344} 345