xref: /webtrees/app/Factories/ImageFactory.php (revision 9458f20a3c5e8e676f65488153a8d8a76b1773fb)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2022 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\Image\Constraint;
32use Intervention\Image\Exception\NotReadableException;
33use Intervention\Image\Exception\NotSupportedException;
34use Intervention\Image\Image;
35use Intervention\Image\ImageManager;
36use League\Flysystem\FilesystemException;
37use League\Flysystem\FilesystemOperator;
38use League\Flysystem\UnableToReadFile;
39use League\Flysystem\UnableToRetrieveMetadata;
40use Psr\Http\Message\ResponseInterface;
41use RuntimeException;
42use Throwable;
43
44use function addcslashes;
45use function basename;
46use function extension_loaded;
47use function get_class;
48use function implode;
49use function pathinfo;
50use function response;
51use function str_contains;
52use function view;
53
54use const PATHINFO_EXTENSION;
55
56/**
57 * Make an image (from another image).
58 */
59class ImageFactory implements ImageFactoryInterface
60{
61    // Imagick can detect the quality setting for images.  GD cannot.
62    protected const GD_DEFAULT_IMAGE_QUALITY     = 90;
63    protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70;
64
65    protected const WATERMARK_FILE = 'resources/img/watermark.png';
66
67    protected const THUMBNAIL_CACHE_TTL = 8640000;
68
69    protected const INTERVENTION_DRIVERS = ['imagick', 'gd'];
70
71    public const SUPPORTED_FORMATS = [
72        'image/jpeg' => 'jpg',
73        'image/png'  => 'png',
74        'image/gif'  => 'gif',
75        'image/tiff' => 'tif',
76        'image/bmp'  => 'bmp',
77        'image/webp' => 'webp',
78    ];
79
80    /**
81     * Send the original file - either inline or as a download.
82     *
83     * @param FilesystemOperator $filesystem
84     * @param string             $path
85     * @param bool               $download
86     *
87     * @return ResponseInterface
88     */
89    public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface
90    {
91        try {
92            try {
93                $mime_type = $filesystem->mimeType($path);
94            } catch (UnableToRetrieveMetadata $ex) {
95                $mime_type = Mime::DEFAULT_TYPE;
96            }
97
98            $filename = $download ? addcslashes(basename($path), '"') : '';
99
100            return $this->imageResponse($filesystem->read($path), $mime_type, $filename);
101        } catch (UnableToReadFile | FilesystemException $ex) {
102            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND)
103                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
104        }
105    }
106
107    /**
108     * Send a thumbnail.
109     *
110     * @param FilesystemOperator $filesystem
111     * @param string             $path
112     * @param int                $width
113     * @param int                $height
114     * @param string             $fit
115     *
116     *
117     * @return ResponseInterface
118     */
119    public function thumbnailResponse(
120        FilesystemOperator $filesystem,
121        string $path,
122        int $width,
123        int $height,
124        string $fit
125    ): ResponseInterface {
126        try {
127            $image = $this->imageManager()->make($filesystem->readStream($path));
128            $image = $this->autorotateImage($image);
129            $image = $this->resizeImage($image, $width, $height, $fit);
130
131            $format  = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg';
132            $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY);
133            $data    = (string) $image->encode($format, $quality);
134
135            return $this->imageResponse($data, $image->mime(), '');
136        } catch (NotReadableException $ex) {
137            return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION))
138                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
139        } catch (FilesystemException | UnableToReadFile $ex) {
140            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND)
141                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
142        } catch (Throwable $ex) {
143            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
144                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
145        }
146    }
147
148    /**
149     * Create a full-size version of an image.
150     *
151     * @param MediaFile $media_file
152     * @param bool      $add_watermark
153     * @param bool      $download
154     *
155     * @return ResponseInterface
156     */
157    public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface
158    {
159        $filesystem = $media_file->media()->tree()->mediaFilesystem();
160        $path       = $media_file->filename();
161
162        if (!$add_watermark || !$media_file->isImage()) {
163            return $this->fileResponse($filesystem, $path, $download);
164        }
165
166        try {
167            $image     = $this->imageManager()->make($filesystem->readStream($path));
168            $image     = $this->autorotateImage($image);
169            $watermark = $this->createWatermark($image->width(), $image->height(), $media_file);
170            $image     = $this->addWatermark($image, $watermark);
171            $filename  = $download ? basename($path) : '';
172            $format    = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg';
173            $quality   = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY);
174            $data      = (string) $image->encode($format, $quality);
175
176            return $this->imageResponse($data, $image->mime(), $filename);
177        } catch (NotReadableException $ex) {
178            return $this->replacementImageResponse(pathinfo($path, PATHINFO_EXTENSION))
179                ->withHeader('x-image-exception', $ex->getMessage());
180        } catch (FilesystemException | UnableToReadFile $ex) {
181            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND)
182                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
183        } catch (Throwable $ex) {
184            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
185                ->withHeader('x-image-exception', $ex->getMessage());
186        }
187    }
188
189    /**
190     * Create a smaller version of an image.
191     *
192     * @param MediaFile $media_file
193     * @param int       $width
194     * @param int       $height
195     * @param string    $fit
196     * @param bool      $add_watermark
197     *
198     * @return ResponseInterface
199     */
200    public function mediaFileThumbnailResponse(
201        MediaFile $media_file,
202        int $width,
203        int $height,
204        string $fit,
205        bool $add_watermark
206    ): ResponseInterface {
207        // Where are the images stored.
208        $filesystem = $media_file->media()->tree()->mediaFilesystem();
209
210        // Where is the image stored in the filesystem.
211        $path = $media_file->filename();
212
213        try {
214            $mime_type = $filesystem->mimeType($path);
215
216            $key = implode(':', [
217                $media_file->media()->tree()->name(),
218                $path,
219                $filesystem->lastModified($path),
220                (string) $width,
221                (string) $height,
222                $fit,
223                (string) $add_watermark,
224            ]);
225
226            $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string {
227                $image = $this->imageManager()->make($filesystem->readStream($path));
228                $image = $this->autorotateImage($image);
229                $image = $this->resizeImage($image, $width, $height, $fit);
230
231                if ($add_watermark) {
232                    $watermark = $this->createWatermark($image->width(), $image->height(), $media_file);
233                    $image     = $this->addWatermark($image, $watermark);
234                }
235
236                $format  = static::SUPPORTED_FORMATS[$image->mime()] ?? 'jpg';
237                $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY);
238
239                return (string) $image->encode($format, $quality);
240            };
241
242            // Images and Responses both contain resources - which cannot be serialized.
243            // So cache the raw image data.
244            $data = Registry::cache()->file()->remember($key, $closure, static::THUMBNAIL_CACHE_TTL);
245
246            return $this->imageResponse($data, $mime_type, '');
247        } catch (NotReadableException $ex) {
248            return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION))
249                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
250        } catch (FilesystemException | UnableToReadFile $ex) {
251            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND)
252                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
253        } catch (Throwable $ex) {
254            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
255                ->withHeader('x-thumbnail-exception', get_class($ex) . ': ' . $ex->getMessage());
256        }
257    }
258
259    /**
260     * Does a full-sized image need a watermark?
261     *
262     * @param MediaFile     $media_file
263     * @param UserInterface $user
264     *
265     * @return bool
266     */
267    public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
268    {
269        $tree = $media_file->media()->tree();
270
271        return Auth::accessLevel($tree, $user) > (int) $tree->getPreference('SHOW_NO_WATERMARK');
272    }
273
274    /**
275     * Does a thumbnail image need a watermark?
276     *
277     * @param MediaFile     $media_file
278     * @param UserInterface $user
279     *
280     * @return bool
281     */
282    public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
283    {
284        return $this->fileNeedsWatermark($media_file, $user);
285    }
286
287    /**
288     * Create a watermark image, perhaps specific to a media-file.
289     *
290     * @param int       $width
291     * @param int       $height
292     * @param MediaFile $media_file
293     *
294     * @return Image
295     */
296    public function createWatermark(int $width, int $height, MediaFile $media_file): Image
297    {
298        return $this->imageManager()
299            ->make(Webtrees::ROOT_DIR . static::WATERMARK_FILE)
300            ->resize($width, $height, static function (Constraint $constraint) {
301                $constraint->aspectRatio();
302            });
303    }
304
305    /**
306     * Add a watermark to an image.
307     *
308     * @param Image $image
309     * @param Image $watermark
310     *
311     * @return Image
312     */
313    public function addWatermark(Image $image, Image $watermark): Image
314    {
315        return $image->insert($watermark, 'center');
316    }
317
318    /**
319     * Send a replacement image, to replace one that could not be found or created.
320     *
321     * @param string $text HTTP status code or file extension
322     *
323     * @return ResponseInterface
324     */
325    public function replacementImageResponse(string $text): ResponseInterface
326    {
327        // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing.
328        $svg = view('errors/image-svg', ['status' => $text]);
329
330        // We can't send the actual status code, as browsers won't show images with 4xx/5xx.
331        return response($svg, StatusCodeInterface::STATUS_OK, [
332            'content-type' => 'image/svg+xml',
333        ]);
334    }
335
336    /**
337     * @param string $data
338     * @param string $mime_type
339     * @param string $filename
340     *
341     * @return ResponseInterface
342     */
343    protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface
344    {
345        if ($mime_type === 'image/svg+xml' && str_contains($data, '<script')) {
346            return $this->replacementImageResponse('XSS')
347                ->withHeader('x-image-exception', 'SVG image blocked due to XSS.');
348        }
349
350        // HTML files may contain javascript and iframes, so use content-security-policy to disable them.
351        $response = response($data)
352            ->withHeader('content-type', $mime_type)
353            ->withHeader('content-security-policy', 'script-src none;frame-src none');
354
355        if ($filename === '') {
356            return $response;
357        }
358
359        return $response
360            ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(basename($filename), '"'));
361    }
362
363    /**
364     * @return ImageManager
365     * @throws RuntimeException
366     */
367    protected function imageManager(): ImageManager
368    {
369        foreach (static::INTERVENTION_DRIVERS as $driver) {
370            if (extension_loaded($driver)) {
371                return new ImageManager(['driver' => $driver]);
372            }
373        }
374
375        throw new RuntimeException('No PHP graphics library is installed.  Need Imagick or GD');
376    }
377
378    /**
379     * Apply EXIF rotation to an image.
380     *
381     * @param Image $image
382     *
383     * @return Image
384     */
385    protected function autorotateImage(Image $image): Image
386    {
387        try {
388            // Auto-rotate using EXIF information.
389            return $image->orientate();
390        } catch (NotSupportedException $ex) {
391            // If we can't auto-rotate the image, then don't.
392            return $image;
393        }
394    }
395
396    /**
397     * Resize an image.
398     *
399     * @param Image  $image
400     * @param int    $width
401     * @param int    $height
402     * @param string $fit
403     *
404     * @return Image
405     */
406    protected function resizeImage(Image $image, int $width, int $height, string $fit): Image
407    {
408        switch ($fit) {
409            case 'crop':
410                return $image->fit($width, $height);
411            case 'contain':
412                return $image->resize($width, $height, static function (Constraint $constraint) {
413                    $constraint->aspectRatio();
414                    $constraint->upsize();
415                });
416        }
417
418        return $image;
419    }
420
421    /**
422     * Extract the quality/compression parameter from an image.
423     *
424     * @param Image $image
425     * @param int   $default
426     *
427     * @return int
428     */
429    protected function extractImageQuality(Image $image, int $default): int
430    {
431        $core = $image->getCore();
432
433        if ($core instanceof Imagick) {
434            return $core->getImageCompressionQuality() ?: $default;
435        }
436
437        return $default;
438    }
439}
440