xref: /webtrees/app/Factories/ImageFactory.php (revision 6d24947fac0fb841ad23303de6f03229d5232220)
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\Registry;
28use Fisharebest\Webtrees\Webtrees;
29use Imagick;
30use Intervention\Image\Constraint;
31use Intervention\Image\Exception\NotReadableException;
32use Intervention\Image\Exception\NotSupportedException;
33use Intervention\Image\Image;
34use Intervention\Image\ImageManager;
35use League\Flysystem\FilesystemException;
36use League\Flysystem\FilesystemOperator;
37use League\Flysystem\UnableToReadFile;
38use Psr\Http\Message\ResponseInterface;
39use RuntimeException;
40use Throwable;
41
42use function addcslashes;
43use function basename;
44use function extension_loaded;
45use function get_class;
46use function pathinfo;
47use function response;
48use function strlen;
49use function view;
50
51use const PATHINFO_EXTENSION;
52
53/**
54 * Make an image (from another image).
55 */
56class ImageFactory implements ImageFactoryInterface
57{
58    // Imagick can detect the quality setting for images.  GD cannot.
59    protected const GD_DEFAULT_IMAGE_QUALITY     = 90;
60    protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70;
61
62    protected const WATERMARK_FILE = 'resources/img/watermark.png';
63
64    protected const THUMBNAIL_CACHE_TTL = 8640000;
65
66    protected const INTERVENTION_DRIVERS = ['imagick', 'gd'];
67
68    protected const INTERVENTION_FORMATS = [
69        'image/jpeg' => 'jpg',
70        'image/png'  => 'png',
71        'image/gif'  => 'gif',
72        'image/tiff' => 'tif',
73        'image/bmp'  => 'bmp',
74        'image/webp' => 'webp',
75    ];
76
77    /**
78     * Send the original file - either inline or as a download.
79     *
80     * @param FilesystemOperator $filesystem
81     * @param string             $path
82     * @param bool               $download
83     *
84     * @return ResponseInterface
85     */
86    public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface
87    {
88        try {
89            $data = $filesystem->read($path);
90
91            $headers = [
92                'Content-Type'   => $filesystem->mimeType($path),
93                'Content-Length' => (string) strlen($data),
94            ];
95
96            if ($download) {
97                $headers['Content-Disposition'] = 'attachment; filename="' . addcslashes(basename($path), '"');
98            }
99
100            return response($data, StatusCodeInterface::STATUS_OK, $headers);
101        } catch (FilesystemException $ex) {
102            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
103        }
104    }
105
106    /**
107     * Send a thumbnail.
108     *
109     * @param FilesystemOperator $filesystem
110     * @param string             $path
111     * @param int                $width
112     * @param int                $height
113     * @param string             $fit
114     *
115     *
116     * @return ResponseInterface
117     */
118    public function thumbnailResponse(
119        FilesystemOperator $filesystem,
120        string $path,
121        int $width,
122        int $height,
123        string $fit
124    ): ResponseInterface {
125        try {
126            $image = $this->imageManager()->make($filesystem->readStream($path));
127            $image = $this->autorotateImage($image);
128            $image = $this->resizeImage($image, $width, $height, $fit);
129
130            $format  = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg';
131            $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY);
132            $data    = (string) $image->encode($format, $quality);
133
134            return $this->imageResponse($data, $image->mime(), '');
135        } catch (NotReadableException $ex) {
136            return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION));
137        } catch (UnableToReadFile $ex) {
138            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
139        } catch (Throwable $ex) {
140            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
141                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
142        }
143    }
144
145    /**
146     * Create a full-size version of an image.
147     *
148     * @param MediaFile $media_file
149     * @param bool      $add_watermark
150     * @param bool      $download
151     *
152     * @return ResponseInterface
153     */
154    public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface
155    {
156        $filesystem = Registry::filesystem()->media($media_file->media()->tree());
157        $filename   = $media_file->filename();
158
159        if (!$add_watermark || !$media_file->isImage()) {
160            return $this->fileResponse($filesystem, $filename, $download);
161        }
162
163        try {
164            $image = $this->imageManager()->make($filesystem->readStream($filename));
165            $image = $this->autorotateImage($image);
166
167            $watermark_image = $this->createWatermark($image->width(), $image->height(), $media_file);
168
169            $image = $this->addWatermark($image, $watermark_image);
170
171            $download_filename = $download ? basename($filename) : '';
172
173            $format  = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg';
174            $quality = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY);
175            $data    = (string) $image->encode($format, $quality);
176
177            return $this->imageResponse($data, $image->mime(), $download_filename);
178        } catch (NotReadableException $ex) {
179            return $this->replacementImageResponse(pathinfo($filename, PATHINFO_EXTENSION))
180                ->withHeader('X-Image-Exception', $ex->getMessage());
181        } catch (UnableToReadFile $ex) {
182            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
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 = Registry::filesystem()->media($media_file->media()->tree());
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::INTERVENTION_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        } catch (UnableToReadFile $ex) {
250            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
251        } catch (Throwable $ex) {
252            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
253                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
254        }
255    }
256
257    /**
258     * Does a full-sized image need a watermark?
259     *
260     * @param MediaFile     $media_file
261     * @param UserInterface $user
262     *
263     * @return bool
264     */
265    public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
266    {
267        $tree = $media_file->media()->tree();
268
269        return Auth::accessLevel($tree, $user) > $tree->getPreference('SHOW_NO_WATERMARK');
270    }
271
272    /**
273     * Does a thumbnail image need a watermark?
274     *
275     * @param MediaFile     $media_file
276     * @param UserInterface $user
277     *
278     * @return bool
279     */
280    public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
281    {
282        return $this->fileNeedsWatermark($media_file, $user);
283    }
284
285    /**
286     * Create a watermark image, perhaps specific to a media-file.
287     *
288     * @param int       $width
289     * @param int       $height
290     * @param MediaFile $media_file
291     *
292     * @return Image
293     */
294    public function createWatermark(int $width, int $height, MediaFile $media_file): Image
295    {
296        return $this->imageManager()
297            ->make(Webtrees::ROOT_DIR . static::WATERMARK_FILE)
298            ->resize($width, $height, static function (Constraint $constraint) {
299                $constraint->aspectRatio();
300            });
301    }
302
303    /**
304     * Add a watermark to an image.
305     *
306     * @param Image $image
307     * @param Image $watermark
308     *
309     * @return Image
310     */
311    public function addWatermark(Image $image, Image $watermark): Image
312    {
313        return $image->insert($watermark, 'center');
314    }
315
316    /**
317     * Send a replacement image, to replace one that could not be found or created.
318     *
319     * @param string $text HTTP status code or file extension
320     *
321     * @return ResponseInterface
322     */
323    public function replacementImageResponse(string $text): ResponseInterface
324    {
325        // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing.
326        $svg = view('errors/image-svg', ['status' => $text]);
327
328        // We can't send the actual status code, as browsers won't show images with 4xx/5xx.
329        return response($svg, StatusCodeInterface::STATUS_OK, [
330            'Content-Type' => 'image/svg+xml',
331        ]);
332    }
333
334    /**
335     * @param string $data
336     * @param string $mime_type
337     * @param string $filename
338     *
339     * @return ResponseInterface
340     */
341    protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface
342    {
343        $headers = [
344            'Content-Type'   => $mime_type,
345            'Content-Length' => (string) strlen($data),
346        ];
347
348        if ($filename !== '') {
349            $headers['Content-Disposition'] = 'attachment; filename="' . addcslashes(basename($filename), '"');
350        }
351
352        return response($data, StatusCodeInterface::STATUS_OK, $headers);
353    }
354
355    /**
356     * @return ImageManager
357     * @throws RuntimeException
358     */
359    protected function imageManager(): ImageManager
360    {
361        foreach (static::INTERVENTION_DRIVERS as $driver) {
362            if (extension_loaded($driver)) {
363                return new ImageManager(['driver' => $driver]);
364            }
365        }
366
367        throw new RuntimeException('No PHP graphics library is installed.  Need Imagick or GD');
368    }
369
370    /**
371     * Apply EXIF rotation to an image.
372     *
373     * @param Image $image
374     *
375     * @return Image
376     */
377    protected function autorotateImage(Image $image): Image
378    {
379        try {
380            // Auto-rotate using EXIF information.
381            return $image->orientate();
382        } catch (NotSupportedException $ex) {
383            // If we can't auto-rotate the image, then don't.
384            return $image;
385        }
386    }
387
388    /**
389     * Resize an image.
390     *
391     * @param Image  $image
392     * @param int    $width
393     * @param int    $height
394     * @param string $fit
395     *
396     * @return Image
397     */
398    protected function resizeImage(Image $image, int $width, int $height, string $fit): Image
399    {
400        switch ($fit) {
401            case 'crop':
402                return $image->fit($width, $height);
403            case 'contain':
404                return $image->resize($width, $height, static function (Constraint $constraint) {
405                    $constraint->aspectRatio();
406                    $constraint->upsize();
407                });
408        }
409
410        return $image;
411    }
412
413    /**
414     * Extract the quality/compression parameter from an image.
415     *
416     * @param Image $image
417     * @param int   $default
418     *
419     * @return int
420     */
421    protected function extractImageQuality(Image $image, int $default): int
422    {
423        $core = $image->getCore();
424
425        if ($core instanceof Imagick) {
426            return $core->getImageCompressionQuality() ?: $default;
427        }
428
429        return $default;
430    }
431}
432