16577bfc3SGreg Roach<?php 26577bfc3SGreg Roach 36577bfc3SGreg Roach/** 46577bfc3SGreg Roach * webtrees: online genealogy 5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team 66577bfc3SGreg Roach * This program is free software: you can redistribute it and/or modify 76577bfc3SGreg Roach * it under the terms of the GNU General Public License as published by 86577bfc3SGreg Roach * the Free Software Foundation, either version 3 of the License, or 96577bfc3SGreg Roach * (at your option) any later version. 106577bfc3SGreg Roach * This program is distributed in the hope that it will be useful, 116577bfc3SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of 126577bfc3SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 136577bfc3SGreg Roach * GNU General Public License for more details. 146577bfc3SGreg Roach * You should have received a copy of the GNU General Public License 1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>. 166577bfc3SGreg Roach */ 176577bfc3SGreg Roach 186577bfc3SGreg Roachdeclare(strict_types=1); 196577bfc3SGreg Roach 206577bfc3SGreg Roachnamespace Fisharebest\Webtrees\Factories; 216577bfc3SGreg Roach 226577bfc3SGreg Roachuse Fig\Http\Message\StatusCodeInterface; 236577bfc3SGreg Roachuse Fisharebest\Webtrees\Auth; 246577bfc3SGreg Roachuse Fisharebest\Webtrees\Contracts\ImageFactoryInterface; 256577bfc3SGreg Roachuse Fisharebest\Webtrees\Contracts\UserInterface; 266577bfc3SGreg Roachuse Fisharebest\Webtrees\MediaFile; 27f0448b68SGreg Roachuse Fisharebest\Webtrees\Mime; 286b9cb339SGreg Roachuse Fisharebest\Webtrees\Registry; 296577bfc3SGreg Roachuse Fisharebest\Webtrees\Webtrees; 306577bfc3SGreg Roachuse Imagick; 31*06c3e14eSGreg Roachuse Intervention\Gif\Exceptions\NotReadableException; 32*06c3e14eSGreg Roachuse Intervention\Image\Drivers\Gd\Driver as GdDriver; 33*06c3e14eSGreg Roachuse Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; 346577bfc3SGreg Roachuse Intervention\Image\ImageManager; 35*06c3e14eSGreg Roachuse Intervention\Image\Interfaces\ImageInterface; 36*06c3e14eSGreg Roachuse InvalidArgumentException; 37f7cf8a15SGreg Roachuse League\Flysystem\FilesystemException; 38f7cf8a15SGreg Roachuse League\Flysystem\FilesystemOperator; 39f32d77e6SGreg Roachuse League\Flysystem\UnableToReadFile; 40f0448b68SGreg Roachuse League\Flysystem\UnableToRetrieveMetadata; 416577bfc3SGreg Roachuse Psr\Http\Message\ResponseInterface; 426577bfc3SGreg Roachuse RuntimeException; 436577bfc3SGreg Roachuse Throwable; 446577bfc3SGreg Roach 456577bfc3SGreg Roachuse function addcslashes; 466577bfc3SGreg Roachuse function basename; 476577bfc3SGreg Roachuse function extension_loaded; 48f32d77e6SGreg Roachuse function get_class; 497729532eSGreg Roachuse function implode; 506577bfc3SGreg Roachuse function pathinfo; 516577bfc3SGreg Roachuse function response; 527729532eSGreg Roachuse function str_contains; 536577bfc3SGreg Roachuse function view; 546577bfc3SGreg Roach 556577bfc3SGreg Roachuse const PATHINFO_EXTENSION; 566577bfc3SGreg Roach 576577bfc3SGreg Roach/** 586577bfc3SGreg Roach * Make an image (from another image). 596577bfc3SGreg Roach */ 606577bfc3SGreg Roachclass ImageFactory implements ImageFactoryInterface 616577bfc3SGreg Roach{ 626577bfc3SGreg Roach // Imagick can detect the quality setting for images. GD cannot. 636577bfc3SGreg Roach protected const GD_DEFAULT_IMAGE_QUALITY = 90; 646577bfc3SGreg Roach protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70; 656577bfc3SGreg Roach 666577bfc3SGreg Roach protected const WATERMARK_FILE = 'resources/img/watermark.png'; 676577bfc3SGreg Roach 686577bfc3SGreg Roach protected const THUMBNAIL_CACHE_TTL = 8640000; 696577bfc3SGreg Roach 7071166947SGreg Roach public const SUPPORTED_FORMATS = [ 716577bfc3SGreg Roach 'image/jpeg' => 'jpg', 726577bfc3SGreg Roach 'image/png' => 'png', 736577bfc3SGreg Roach 'image/gif' => 'gif', 746577bfc3SGreg Roach 'image/tiff' => 'tif', 756577bfc3SGreg Roach 'image/bmp' => 'bmp', 766577bfc3SGreg Roach 'image/webp' => 'webp', 776577bfc3SGreg Roach ]; 786577bfc3SGreg Roach 796577bfc3SGreg Roach /** 806577bfc3SGreg Roach * Send the original file - either inline or as a download. 816577bfc3SGreg Roach */ 82f7cf8a15SGreg Roach public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface 836577bfc3SGreg Roach { 846577bfc3SGreg Roach try { 85f0448b68SGreg Roach try { 86*06c3e14eSGreg Roach $mime_type = $filesystem->mimeType(path: $path); 8728d026adSGreg Roach } catch (UnableToRetrieveMetadata) { 887729532eSGreg Roach $mime_type = Mime::DEFAULT_TYPE; 89f0448b68SGreg Roach } 90f0448b68SGreg Roach 91*06c3e14eSGreg Roach $filename = $download ? addcslashes(string: basename(path: $path), characters: '"') : ''; 926577bfc3SGreg Roach 93*06c3e14eSGreg Roach return $this->imageResponse(data: $filesystem->read(location: $path), mime_type: $mime_type, filename: $filename); 94fc904122SGreg Roach } catch (UnableToReadFile | FilesystemException $ex) { 95*06c3e14eSGreg Roach return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 96*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 976577bfc3SGreg Roach } 986577bfc3SGreg Roach } 996577bfc3SGreg Roach 1006577bfc3SGreg Roach /** 1016577bfc3SGreg Roach * Send a thumbnail. 1026577bfc3SGreg Roach */ 1036577bfc3SGreg Roach public function thumbnailResponse( 104f7cf8a15SGreg Roach FilesystemOperator $filesystem, 1056577bfc3SGreg Roach string $path, 1066577bfc3SGreg Roach int $width, 1076577bfc3SGreg Roach int $height, 1086577bfc3SGreg Roach string $fit 1096577bfc3SGreg Roach ): ResponseInterface { 1106577bfc3SGreg Roach try { 111*06c3e14eSGreg Roach $mime_type = $filesystem->mimeType(path: $path); 112*06c3e14eSGreg Roach $image = $this->imageManager()->read(input: $filesystem->readStream($path)); 113*06c3e14eSGreg Roach $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); 114*06c3e14eSGreg Roach $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); 115*06c3e14eSGreg Roach $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); 1166577bfc3SGreg Roach 117*06c3e14eSGreg Roach return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); 118f0448b68SGreg Roach } catch (FilesystemException | UnableToReadFile $ex) { 119*06c3e14eSGreg Roach return $this 120*06c3e14eSGreg Roach ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 121*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 122*06c3e14eSGreg Roach } catch (RuntimeException $ex) { 123*06c3e14eSGreg Roach return $this 124*06c3e14eSGreg Roach ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) 125*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 1266577bfc3SGreg Roach } catch (Throwable $ex) { 127*06c3e14eSGreg Roach return $this 128*06c3e14eSGreg Roach ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 129*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 1306577bfc3SGreg Roach } 1316577bfc3SGreg Roach } 1326577bfc3SGreg Roach 1336577bfc3SGreg Roach /** 1346577bfc3SGreg Roach * Create a full-size version of an image. 1356577bfc3SGreg Roach */ 1366577bfc3SGreg Roach public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface 1376577bfc3SGreg Roach { 1389458f20aSGreg Roach $filesystem = $media_file->media()->tree()->mediaFilesystem(); 1397729532eSGreg Roach $path = $media_file->filename(); 1406577bfc3SGreg Roach 1416577bfc3SGreg Roach if (!$add_watermark || !$media_file->isImage()) { 142*06c3e14eSGreg Roach return $this->fileResponse(filesystem: $filesystem, path: $path, download: $download); 1436577bfc3SGreg Roach } 1446577bfc3SGreg Roach 1456577bfc3SGreg Roach try { 146*06c3e14eSGreg Roach $mime_type = $media_file->mimeType(); 147*06c3e14eSGreg Roach $image = $this->imageManager()->read(input: $filesystem->readStream($path)); 148*06c3e14eSGreg Roach $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); 149*06c3e14eSGreg Roach $image = $this->addWatermark(image: $image, watermark: $watermark); 150*06c3e14eSGreg Roach $filename = $download ? basename(path: $path) : ''; 151*06c3e14eSGreg Roach $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_IMAGE_QUALITY); 152*06c3e14eSGreg Roach $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); 1536577bfc3SGreg Roach 154*06c3e14eSGreg Roach return $this->imageResponse(data: $data, mime_type: $mime_type, filename: $filename); 1556577bfc3SGreg Roach } catch (NotReadableException $ex) { 156*06c3e14eSGreg Roach return $this->replacementImageResponse(text: pathinfo(path: $path, flags: PATHINFO_EXTENSION)) 1576172e7f6SGreg Roach ->withHeader('x-image-exception', $ex->getMessage()); 158f0448b68SGreg Roach } catch (FilesystemException | UnableToReadFile $ex) { 159*06c3e14eSGreg Roach return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 160*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 1616577bfc3SGreg Roach } catch (Throwable $ex) { 162*06c3e14eSGreg Roach return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 1636172e7f6SGreg Roach ->withHeader('x-image-exception', $ex->getMessage()); 1646577bfc3SGreg Roach } 1656577bfc3SGreg Roach } 1666577bfc3SGreg Roach 1676577bfc3SGreg Roach /** 1686577bfc3SGreg Roach * Create a smaller version of an image. 1696577bfc3SGreg Roach */ 1706577bfc3SGreg Roach public function mediaFileThumbnailResponse( 1716577bfc3SGreg Roach MediaFile $media_file, 1726577bfc3SGreg Roach int $width, 1736577bfc3SGreg Roach int $height, 1746577bfc3SGreg Roach string $fit, 1756577bfc3SGreg Roach bool $add_watermark 1766577bfc3SGreg Roach ): ResponseInterface { 1776577bfc3SGreg Roach // Where are the images stored. 1789458f20aSGreg Roach $filesystem = $media_file->media()->tree()->mediaFilesystem(); 1796577bfc3SGreg Roach 1806577bfc3SGreg Roach // Where is the image stored in the filesystem. 1816577bfc3SGreg Roach $path = $media_file->filename(); 1826577bfc3SGreg Roach 1836577bfc3SGreg Roach try { 184*06c3e14eSGreg Roach $mime_type = $filesystem->mimeType(path: $path); 1856577bfc3SGreg Roach 186*06c3e14eSGreg Roach $key = implode(separator: ':', array: [ 1876577bfc3SGreg Roach $media_file->media()->tree()->name(), 1886577bfc3SGreg Roach $path, 189*06c3e14eSGreg Roach $filesystem->lastModified(path: $path), 1906577bfc3SGreg Roach (string) $width, 1916577bfc3SGreg Roach (string) $height, 1926577bfc3SGreg Roach $fit, 1936577bfc3SGreg Roach (string) $add_watermark, 1946577bfc3SGreg Roach ]); 1956577bfc3SGreg Roach 1966577bfc3SGreg Roach $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string { 197*06c3e14eSGreg Roach $image = $this->imageManager()->read(input: $filesystem->readStream($path)); 198*06c3e14eSGreg Roach $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); 1996577bfc3SGreg Roach 2006577bfc3SGreg Roach if ($add_watermark) { 201*06c3e14eSGreg Roach $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); 202*06c3e14eSGreg Roach $image = $this->addWatermark(image: $image, watermark: $watermark); 2036577bfc3SGreg Roach } 2046577bfc3SGreg Roach 205*06c3e14eSGreg Roach $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); 2066577bfc3SGreg Roach 207*06c3e14eSGreg Roach return $image->encodeByMediaType(type: $media_file->mimeType(), quality: $quality)->toString(); 2086577bfc3SGreg Roach }; 2096577bfc3SGreg Roach 2106577bfc3SGreg Roach // Images and Responses both contain resources - which cannot be serialized. 2116577bfc3SGreg Roach // So cache the raw image data. 212*06c3e14eSGreg Roach $data = Registry::cache()->file()->remember(key: $key, closure: $closure, ttl: static::THUMBNAIL_CACHE_TTL); 2136577bfc3SGreg Roach 214*06c3e14eSGreg Roach return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); 2156577bfc3SGreg Roach } catch (NotReadableException $ex) { 216*06c3e14eSGreg Roach return $this 217*06c3e14eSGreg Roach ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) 218*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 219f0448b68SGreg Roach } catch (FilesystemException | UnableToReadFile $ex) { 220*06c3e14eSGreg Roach return $this 221*06c3e14eSGreg Roach ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) 222*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 2236577bfc3SGreg Roach } catch (Throwable $ex) { 224*06c3e14eSGreg Roach return $this 225*06c3e14eSGreg Roach ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 226*06c3e14eSGreg Roach ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); 2276577bfc3SGreg Roach } 2286577bfc3SGreg Roach } 2296577bfc3SGreg Roach 2306577bfc3SGreg Roach /** 2316577bfc3SGreg Roach * Does a full-sized image need a watermark? 2326577bfc3SGreg Roach */ 2336577bfc3SGreg Roach public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 2346577bfc3SGreg Roach { 2356577bfc3SGreg Roach $tree = $media_file->media()->tree(); 2366577bfc3SGreg Roach 237*06c3e14eSGreg Roach return Auth::accessLevel(tree: $tree, user: $user) > (int) $tree->getPreference(setting_name: 'SHOW_NO_WATERMARK'); 2386577bfc3SGreg Roach } 2396577bfc3SGreg Roach 2406577bfc3SGreg Roach /** 2416577bfc3SGreg Roach * Does a thumbnail image need a watermark? 2426577bfc3SGreg Roach */ 2436577bfc3SGreg Roach public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool 2446577bfc3SGreg Roach { 245*06c3e14eSGreg Roach return $this->fileNeedsWatermark(media_file: $media_file, user: $user); 2466577bfc3SGreg Roach } 2476577bfc3SGreg Roach 2486577bfc3SGreg Roach /** 2496577bfc3SGreg Roach * Create a watermark image, perhaps specific to a media-file. 2506577bfc3SGreg Roach */ 251*06c3e14eSGreg Roach public function createWatermark(int $width, int $height, MediaFile $media_file): ImageInterface 2526577bfc3SGreg Roach { 2536577bfc3SGreg Roach return $this->imageManager() 254*06c3e14eSGreg Roach ->read(input: Webtrees::ROOT_DIR . static::WATERMARK_FILE) 255*06c3e14eSGreg Roach ->contain(width: $width, height: $height); 2566577bfc3SGreg Roach } 2576577bfc3SGreg Roach 2586577bfc3SGreg Roach /** 2596577bfc3SGreg Roach * Add a watermark to an image. 2606577bfc3SGreg Roach */ 261*06c3e14eSGreg Roach public function addWatermark(ImageInterface $image, ImageInterface $watermark): ImageInterface 2626577bfc3SGreg Roach { 263*06c3e14eSGreg Roach return $image->place(element: $watermark, position: 'center'); 2646577bfc3SGreg Roach } 2656577bfc3SGreg Roach 2666577bfc3SGreg Roach /** 2676577bfc3SGreg Roach * Send a replacement image, to replace one that could not be found or created. 2686577bfc3SGreg Roach */ 2696577bfc3SGreg Roach public function replacementImageResponse(string $text): ResponseInterface 2706577bfc3SGreg Roach { 2716577bfc3SGreg Roach // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing. 272*06c3e14eSGreg Roach $svg = view(name: 'errors/image-svg', data: ['status' => $text]); 2736577bfc3SGreg Roach 2746577bfc3SGreg Roach // We can't send the actual status code, as browsers won't show images with 4xx/5xx. 275*06c3e14eSGreg Roach return response(content: $svg, code: StatusCodeInterface::STATUS_OK, headers: [ 2767729532eSGreg Roach 'content-type' => 'image/svg+xml', 2776577bfc3SGreg Roach ]); 2786577bfc3SGreg Roach } 2796577bfc3SGreg Roach 2806577bfc3SGreg Roach /** 281*06c3e14eSGreg Roach * Create a response from image data. 2826577bfc3SGreg Roach */ 2836577bfc3SGreg Roach protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface 2846577bfc3SGreg Roach { 285*06c3e14eSGreg Roach if ($mime_type === 'image/svg+xml' && str_contains(haystack: $data, needle: '<script')) { 286*06c3e14eSGreg Roach return $this->replacementImageResponse(text: 'XSS') 2876172e7f6SGreg Roach ->withHeader('x-image-exception', 'SVG image blocked due to XSS.'); 2886577bfc3SGreg Roach } 2896577bfc3SGreg Roach 2908340714cSGreg Roach // HTML files may contain javascript and iframes, so use content-security-policy to disable them. 2917729532eSGreg Roach $response = response($data) 292fc904122SGreg Roach ->withHeader('content-type', $mime_type) 2938340714cSGreg Roach ->withHeader('content-security-policy', 'script-src none;frame-src none'); 2947729532eSGreg Roach 2957729532eSGreg Roach if ($filename === '') { 2967729532eSGreg Roach return $response; 2977729532eSGreg Roach } 2987729532eSGreg Roach 2997729532eSGreg Roach return $response 300*06c3e14eSGreg Roach ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(string: basename(path: $filename), characters: '"')); 3016577bfc3SGreg Roach } 3026577bfc3SGreg Roach 3036577bfc3SGreg Roach /** 304*06c3e14eSGreg Roach * Choose an image library, based on what is installed. 3056577bfc3SGreg Roach */ 3066577bfc3SGreg Roach protected function imageManager(): ImageManager 3076577bfc3SGreg Roach { 308*06c3e14eSGreg Roach if (extension_loaded(extension: 'imagick')) { 309*06c3e14eSGreg Roach return new ImageManager(driver: new ImagickDriver()); 3106577bfc3SGreg Roach } 3116577bfc3SGreg Roach 312*06c3e14eSGreg Roach if (extension_loaded(extension: 'gd')) { 313*06c3e14eSGreg Roach return new ImageManager(driver: new GdDriver()); 3146577bfc3SGreg Roach } 3156577bfc3SGreg Roach 316*06c3e14eSGreg Roach throw new RuntimeException(message: 'No PHP graphics library is installed. Need Imagick or GD'); 3176577bfc3SGreg Roach } 3186577bfc3SGreg Roach 3196577bfc3SGreg Roach /** 3206577bfc3SGreg Roach * Resize an image. 3216577bfc3SGreg Roach */ 322*06c3e14eSGreg Roach protected function resizeImage(ImageInterface $image, int $width, int $height, string $fit): ImageInterface 3236577bfc3SGreg Roach { 324*06c3e14eSGreg Roach return match ($fit) { 325*06c3e14eSGreg Roach 'crop' => $image->cover(width: $width, height: $height), 326*06c3e14eSGreg Roach 'contain' => $image->scale(width: $width, height: $height), 327*06c3e14eSGreg Roach default => throw new InvalidArgumentException(message: 'Unknown fit type: ' . $fit), 328*06c3e14eSGreg Roach }; 3296577bfc3SGreg Roach } 3306577bfc3SGreg Roach 3316577bfc3SGreg Roach /** 3326577bfc3SGreg Roach * Extract the quality/compression parameter from an image. 3336577bfc3SGreg Roach */ 334*06c3e14eSGreg Roach protected function extractImageQuality(ImageInterface $image, int $default): int 3356577bfc3SGreg Roach { 336*06c3e14eSGreg Roach $native = $image->core()->native(); 3376577bfc3SGreg Roach 338*06c3e14eSGreg Roach if ($native instanceof Imagick) { 339*06c3e14eSGreg Roach return $native->getImageCompressionQuality(); 3406577bfc3SGreg Roach } 3416577bfc3SGreg Roach 3426577bfc3SGreg Roach return $default; 3436577bfc3SGreg Roach } 3446577bfc3SGreg Roach} 345