xref: /webtrees/app/Factories/ImageFactory.php (revision e873f434551745f888937263ff89e80db3b0f785)
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 int GD_DEFAULT_IMAGE_QUALITY     = 90;
64    protected const int GD_DEFAULT_THUMBNAIL_QUALITY = 70;
65
66    protected const string WATERMARK_FILE = 'resources/img/watermark.png';
67
68    protected const int THUMBNAIL_CACHE_TTL = 8640000;
69
70    public const array 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