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