Passed
Push — main ( dd7fc1...81b117 )
by Greg
05:49
created

app/Factories/ImageFactory.php (1 issue)

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
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Factories;
21
22
use Fig\Http\Message\StatusCodeInterface;
23
use Fisharebest\Webtrees\Auth;
24
use Fisharebest\Webtrees\Contracts\ImageFactoryInterface;
25
use Fisharebest\Webtrees\Contracts\UserInterface;
26
use Fisharebest\Webtrees\MediaFile;
27
use Fisharebest\Webtrees\Mime;
28
use Fisharebest\Webtrees\Registry;
29
use Fisharebest\Webtrees\Webtrees;
30
use Imagick;
31
use Intervention\Image\Constraint;
32
use Intervention\Image\Exception\NotReadableException;
33
use Intervention\Image\Exception\NotSupportedException;
34
use Intervention\Image\Image;
35
use Intervention\Image\ImageManager;
36
use League\Flysystem\FilesystemException;
37
use League\Flysystem\FilesystemOperator;
38
use League\Flysystem\UnableToReadFile;
39
use League\Flysystem\UnableToRetrieveMetadata;
40
use Psr\Http\Message\ResponseInterface;
41
use RuntimeException;
42
use Throwable;
43
44
use function addcslashes;
45
use function basename;
46
use function extension_loaded;
47
use function get_class;
48
use function implode;
49
use function pathinfo;
50
use function response;
51
use function str_contains;
52
use function view;
53
54
use const PATHINFO_EXTENSION;
55
56
/**
57
 * Make an image (from another image).
58
 */
59
class 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
    protected const INTERVENTION_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 (FileNotFoundException $ex) {
0 ignored issues
show
The type Fisharebest\Webtrees\Fac...s\FileNotFoundException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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
                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
138
        } catch (FilesystemException | UnableToReadFile $ex) {
139
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
140
        } catch (Throwable $ex) {
141
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
142
                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
143
        }
144
    }
145
146
    /**
147
     * Create a full-size version of an image.
148
     *
149
     * @param MediaFile $media_file
150
     * @param bool      $add_watermark
151
     * @param bool      $download
152
     *
153
     * @return ResponseInterface
154
     */
155
    public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface
156
    {
157
        $filesystem = Registry::filesystem()->media($media_file->media()->tree());
158
        $path   = $media_file->filename();
159
160
        if (!$add_watermark || !$media_file->isImage()) {
161
            return $this->fileResponse($filesystem, $path, $download);
162
        }
163
164
        try {
165
            $image     = $this->imageManager()->make($filesystem->readStream($path));
166
            $image     = $this->autorotateImage($image);
167
            $watermark = $this->createWatermark($image->width(), $image->height(), $media_file);
168
            $image     = $this->addWatermark($image, $watermark);
169
            $filename  = $download ? basename($path) : '';
170
            $format    = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg';
171
            $quality   = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY);
172
            $data      = (string) $image->encode($format, $quality);
173
174
            return $this->imageResponse($data, $image->mime(), $filename);
175
        } catch (NotReadableException $ex) {
176
            return $this->replacementImageResponse(pathinfo($path, PATHINFO_EXTENSION))
177
                ->withHeader('X-Image-Exception', $ex->getMessage());
178
        } catch (FilesystemException | UnableToReadFile $ex) {
179
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
180
        } catch (Throwable $ex) {
181
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
182
                ->withHeader('X-Image-Exception', $ex->getMessage());
183
        }
184
    }
185
186
    /**
187
     * Create a smaller version of an image.
188
     *
189
     * @param MediaFile $media_file
190
     * @param int       $width
191
     * @param int       $height
192
     * @param string    $fit
193
     * @param bool      $add_watermark
194
     *
195
     * @return ResponseInterface
196
     */
197
    public function mediaFileThumbnailResponse(
198
        MediaFile $media_file,
199
        int $width,
200
        int $height,
201
        string $fit,
202
        bool $add_watermark
203
    ): ResponseInterface {
204
        // Where are the images stored.
205
        $filesystem = Registry::filesystem()->media($media_file->media()->tree());
206
207
        // Where is the image stored in the filesystem.
208
        $path = $media_file->filename();
209
210
        try {
211
            $mime_type = $filesystem->mimeType($path);
212
213
            $key = implode(':', [
214
                $media_file->media()->tree()->name(),
215
                $path,
216
                $filesystem->lastModified($path),
217
                (string) $width,
218
                (string) $height,
219
                $fit,
220
                (string) $add_watermark,
221
            ]);
222
223
            $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string {
224
                $image = $this->imageManager()->make($filesystem->readStream($path));
225
                $image = $this->autorotateImage($image);
226
                $image = $this->resizeImage($image, $width, $height, $fit);
227
228
                if ($add_watermark) {
229
                    $watermark = $this->createWatermark($image->width(), $image->height(), $media_file);
230
                    $image     = $this->addWatermark($image, $watermark);
231
                }
232
233
                $format  = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg';
234
                $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY);
235
236
                return (string) $image->encode($format, $quality);
237
            };
238
239
            // Images and Responses both contain resources - which cannot be serialized.
240
            // So cache the raw image data.
241
            $data = Registry::cache()->file()->remember($key, $closure, static::THUMBNAIL_CACHE_TTL);
242
243
            return $this->imageResponse($data, $mime_type, '');
244
        } catch (NotReadableException $ex) {
245
            return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION))
246
                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
247
        } catch (FilesystemException | UnableToReadFile $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', get_class($ex) . ': ' . $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
        if ($mime_type === 'image/svg+xml' && str_contains($data, '<script')) {
342
            return $this->replacementImageResponse('XSS')
343
                ->withHeader('X-Image-Exception', 'SVG image blocked due to XSS.');
344
        }
345
346
        $response = response($data)
347
            ->withHeader('content-type', $mime_type);
348
349
        if ($filename === '') {
350
            return $response;
351
        }
352
353
        return $response
354
            ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(basename($filename), '"'));
355
    }
356
357
    /**
358
     * @return ImageManager
359
     * @throws RuntimeException
360
     */
361
    protected function imageManager(): ImageManager
362
    {
363
        foreach (static::INTERVENTION_DRIVERS as $driver) {
364
            if (extension_loaded($driver)) {
365
                return new ImageManager(['driver' => $driver]);
366
            }
367
        }
368
369
        throw new RuntimeException('No PHP graphics library is installed.  Need Imagick or GD');
370
    }
371
372
    /**
373
     * Apply EXIF rotation to an image.
374
     *
375
     * @param Image $image
376
     *
377
     * @return Image
378
     */
379
    protected function autorotateImage(Image $image): Image
380
    {
381
        try {
382
            // Auto-rotate using EXIF information.
383
            return $image->orientate();
384
        } catch (NotSupportedException $ex) {
385
            // If we can't auto-rotate the image, then don't.
386
            return $image;
387
        }
388
    }
389
390
    /**
391
     * Resize an image.
392
     *
393
     * @param Image  $image
394
     * @param int    $width
395
     * @param int    $height
396
     * @param string $fit
397
     *
398
     * @return Image
399
     */
400
    protected function resizeImage(Image $image, int $width, int $height, string $fit): Image
401
    {
402
        switch ($fit) {
403
            case 'crop':
404
                return $image->fit($width, $height);
405
            case 'contain':
406
                return $image->resize($width, $height, static function (Constraint $constraint) {
407
                    $constraint->aspectRatio();
408
                    $constraint->upsize();
409
                });
410
        }
411
412
        return $image;
413
    }
414
415
    /**
416
     * Extract the quality/compression parameter from an image.
417
     *
418
     * @param Image $image
419
     * @param int   $default
420
     *
421
     * @return int
422
     */
423
    protected function extractImageQuality(Image $image, int $default): int
424
    {
425
        $core = $image->getCore();
426
427
        if ($core instanceof Imagick) {
428
            return $core->getImageCompressionQuality() ?: $default;
429
        }
430
431
        return $default;
432
    }
433
}
434