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