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