. */ declare(strict_types=1); namespace Fisharebest\Webtrees\Factories; use DOMDocument; use DOMElement; use Fig\Http\Message\StatusCodeInterface; use Fisharebest\Webtrees\Auth; use Fisharebest\Webtrees\Contracts\ImageFactoryInterface; use Fisharebest\Webtrees\Contracts\UserInterface; use Fisharebest\Webtrees\MediaFile; use Fisharebest\Webtrees\Mime; use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\PhpService; use Fisharebest\Webtrees\Webtrees; use Imagick; use Intervention\Image\Drivers\Gd\Driver as GdDriver; use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; use Intervention\Image\Exceptions\ImageException; use Intervention\Image\ImageManager; use Intervention\Image\Interfaces\ImageInterface; use InvalidArgumentException; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; use League\Flysystem\UnableToRetrieveMetadata; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Throwable; use function addcslashes; use function basename; use function get_class; use function implode; use function libxml_clear_errors; use function libxml_use_internal_errors; use function pathinfo; use function preg_replace; use function response; use function str_starts_with; use function strtolower; use function view; use const LIBXML_NONET; use const PATHINFO_EXTENSION; class ImageFactory implements ImageFactoryInterface { // Imagick can detect the quality setting for images. GD cannot. protected const int GD_DEFAULT_IMAGE_QUALITY = 90; protected const int GD_DEFAULT_THUMBNAIL_QUALITY = 70; protected const string WATERMARK_FILE = 'resources/img/watermark.png'; protected const int THUMBNAIL_CACHE_TTL = 8640000; private const array DANGEROUS_SVG_TAGS = [ 'script', 'foreignobject', 'iframe', 'object', 'embed', 'handler', ]; public const array SUPPORTED_FORMATS = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/tiff' => 'tif', 'image/bmp' => 'bmp', 'image/webp' => 'webp', ]; public function __construct(private PhpService $php_service) { } public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface { try { try { $mime_type = $filesystem->mimeType(path: $path); } catch (UnableToRetrieveMetadata) { $mime_type = Mime::DEFAULT_TYPE; } $filename = $download ? addcslashes(string: basename(path: $path), characters: '"') : ''; return $this->imageResponse(data: $filesystem->read(location: $path), mime_type: $mime_type, filename: $filename); } catch (FilesystemException $ex) { return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) ->withHeader('x-file-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } } public function thumbnailResponse( FilesystemOperator $filesystem, string $path, int $width, int $height, string $fit ): ResponseInterface { try { $mime_type = $filesystem->mimeType(path: $path); $image = $this->imageManager()->decodeBinary(binary: $filesystem->read(location: $path)); $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); $data = $image->encodeUsingMediaType(mediaType: $mime_type, quality: $quality)->toString(); return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); } catch (FilesystemException $ex) { return $this ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (ImageException $ex) { return $this ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (Throwable $ex) { return $this ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } } public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface { $filesystem = $media_file->media()->tree()->mediaFilesystem(); $path = $media_file->filename(); if (!$add_watermark || !$media_file->isImage()) { return $this->fileResponse(filesystem: $filesystem, path: $path, download: $download); } try { $mime_type = $media_file->mimeType(); $image = $this->imageManager()->decodeBinary(binary: $filesystem->read(location: $path)); $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); $image = $this->addWatermark(image: $image, watermark: $watermark); $filename = $download ? basename(path: $path) : ''; $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_IMAGE_QUALITY); $data = $image->encodeUsingMediaType(mediaType: $mime_type, quality: $quality)->toString(); return $this->imageResponse(data: $data, mime_type: $mime_type, filename: $filename); } catch (ImageException $ex) { return $this->replacementImageResponse(text: pathinfo(path: $path, flags: PATHINFO_EXTENSION)) ->withHeader('x-image-exception', $ex->getMessage()); } catch (FilesystemException $ex) { return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) ->withHeader('x-image-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (Throwable $ex) { return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) ->withHeader('x-image-exception', $ex->getMessage()); } } public function mediaFileThumbnail( MediaFile $media_file, int $width, int $height, string $fit, bool $add_watermark ): string { // Where are the images stored? $filesystem = $media_file->media()->tree()->mediaFilesystem(); // Where is the image stored in the filesystem? $path = $media_file->filename(); $key = implode(separator: ':', array: [ $media_file->media()->tree()->name(), $path, $filesystem->lastModified(path: $path), (string) $width, (string) $height, $fit, (string) $add_watermark, ]); $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string { $image = $this->imageManager()->decodeBinary(binary: $filesystem->read(location: $path)); $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); if ($add_watermark) { $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); $image = $this->addWatermark(image: $image, watermark: $watermark); } $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); return $image->encodeUsingMediaType(mediaType: $media_file->mimeType(), quality: $quality)->toString(); }; return Registry::cache()->file()->remember(key: $key, closure: $closure, ttl: static::THUMBNAIL_CACHE_TTL); } public function mediaFileThumbnailResponse( MediaFile $media_file, int $width, int $height, string $fit, bool $add_watermark ): ResponseInterface { // Where are the images stored? $filesystem = $media_file->media()->tree()->mediaFilesystem(); // Where is the image stored in the filesystem? $path = $media_file->filename(); try { $mime_type = $filesystem->mimeType(path: $path); $data = $this->mediaFileThumbnail($media_file, $width, $height, $fit, $add_watermark); return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); } catch (FilesystemException $ex) { return $this ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } catch (Throwable $ex) { return $this ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); } } public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool { $tree = $media_file->media()->tree(); return Auth::accessLevel(tree: $tree, user: $user) > (int) $tree->getPreference(setting_name: 'SHOW_NO_WATERMARK'); } public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool { return $this->fileNeedsWatermark(media_file: $media_file, user: $user); } public function createWatermark(int $width, int $height, MediaFile $media_file): ImageInterface { return $this->imageManager() ->decodePath(path: Webtrees::ROOT_DIR . static::WATERMARK_FILE) ->scale(width: $width, height: $height); } public function addWatermark(ImageInterface $image, ImageInterface $watermark): ImageInterface { return $image->insert(image: $watermark, alignment: 'center'); } public function replacementImageResponse(string $text): ResponseInterface { // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing. $svg = view(name: 'errors/image-svg', data: ['status' => $text]); // We can't send the actual status code, as browsers won't show images with 4xx/5xx. return response(content: $svg) ->withHeader('content-type', 'image/svg+xml') ->withHeader('content-security-policy', 'default-src none'); } protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface { if ($mime_type === 'image/svg+xml') { if (!$this->php_service->extensionLoaded(extension: 'dom')) { return $this->replacementImageResponse(text: 'DOM') ->withHeader('x-image-exception', 'Need the PHP dom extension to verify SVG files.'); } if ($this->svgContainsActiveContent(data: $data)) { return $this->replacementImageResponse(text: 'XSS') ->withHeader('x-image-exception', 'SVG image blocked due to XSS.'); } } // HTML files may contain JavaScript and iframes, so use content-security-policy to disable them. $response = response($data) ->withHeader('content-type', $mime_type) ->withHeader('content-security-policy', 'default-src none'); if ($filename === '') { return $response; } $filename = addcslashes(string: basename(path: $filename), characters: '"'); return $response->withHeader('content-disposition', 'attachment; filename="' . $filename . '"'); } /** * Determine whether an SVG document contains active content (script elements, * event-handler attributes, javascript: URLs) that could execute in a browser. * * Although we have a content-security-policy to disable scripts, a user may * download this file and distribute it, so better to block it. */ private function svgContainsActiveContent(string $data): bool { $previous_error_state = libxml_use_internal_errors(true); try { $document = new DOMDocument(); // LIBXML_NONET disables network access so external DTDs and entities // cannot be fetched — mitigates XXE/SSRF on malformed payloads. $loaded = $document->loadXML($data, LIBXML_NONET); if ($loaded === false || !$document->documentElement instanceof DOMElement) { // Malformed SVG — treat conservatively and block. return true; } return $this->svgElementIsDangerous($document->documentElement); } finally { libxml_clear_errors(); libxml_use_internal_errors($previous_error_state); } } private function svgElementIsDangerous(DOMElement $element): bool { if (in_array(strtolower($element->localName), self::DANGEROUS_SVG_TAGS, true)) { return true; } foreach ($element->attributes as $attribute) { $name = strtolower($attribute->nodeName); // Event-handler attributes such as "onload" if (str_starts_with($name, 'on')) { return true; } // Normalize whitespace to catch malformed values that browsers might accept, // such as "java\tscript:". $value = (string) $attribute->nodeValue; $value_compact = preg_replace('/\s+/', '', $value) ?? ''; if (str_starts_with(strtolower($value_compact), 'javascript:')) { return true; } } foreach ($element->childNodes as $child) { if ($child instanceof DOMElement && $this->svgElementIsDangerous(element: $child)) { return true; } } return false; } protected function imageManager(): ImageManager { if ($this->php_service->extensionLoaded(extension: 'imagick')) { return new ImageManager(driver: new ImagickDriver()); } if ($this->php_service->extensionLoaded(extension: 'gd')) { return new ImageManager(driver: new GdDriver()); } throw new RuntimeException(message: 'No PHP graphics library is installed. Need Imagick or GD'); } protected function resizeImage(ImageInterface $image, int $width, int $height, string $fit): ImageInterface { return match ($fit) { 'crop' => $image->cover(width: $width, height: $height), 'contain' => $image->scale(width: $width, height: $height), default => throw new InvalidArgumentException(message: 'Unknown fit type: ' . $fit), }; } protected function extractImageQuality(ImageInterface $image, int $default): int { $native = $image->core()->native(); if ($native instanceof Imagick) { return $native->getImageCompressionQuality(); } return $default; } }