Issues (120)

app/Factories/ImageFactory.php (3 issues)

Labels
Severity
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2022 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\Registry;
28
use Fisharebest\Webtrees\Webtrees;
29
use Imagick;
30
use Intervention\Image\Constraint;
31
use Intervention\Image\Exception\NotReadableException;
32
use Intervention\Image\Exception\NotSupportedException;
33
use Intervention\Image\Image;
34
use Intervention\Image\ImageManager;
35
use League\Flysystem\FileNotFoundException;
36
use League\Flysystem\FilesystemInterface;
37
use Psr\Http\Message\ResponseInterface;
38
use RuntimeException;
39
use Throwable;
40
41
use function addcslashes;
42
use function basename;
43
use function extension_loaded;
44
use function get_class;
45
use function implode;
46
use function pathinfo;
47
use function response;
48
use function str_contains;
49
use function view;
50
51
use const PATHINFO_EXTENSION;
52
53
/**
54
 * Make an image (from another image).
55
 */
56
class 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 FilesystemInterface $filesystem
81
     * @param string              $path
82
     * @param bool                $download
83
     *
84
     * @return ResponseInterface
85
     */
86
    public function fileResponse(FilesystemInterface $filesystem, string $path, bool $download): ResponseInterface
87
    {
88
        try {
89
            $mime_type = $filesystem->getMimetype($path);
90
91
            $filename = $download ? addcslashes(basename($path), '"') : '';
92
93
            return $this->imageResponse($filesystem->read($path), $mime_type, $filename);
0 ignored issues
show
It seems like $mime_type can also be of type false; however, parameter $mime_type of Fisharebest\Webtrees\Fac...actory::imageResponse() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

93
            return $this->imageResponse($filesystem->read($path), /** @scrutinizer ignore-type */ $mime_type, $filename);
Loading history...
It seems like $filesystem->read($path) can also be of type false; however, parameter $data of Fisharebest\Webtrees\Fac...actory::imageResponse() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

93
            return $this->imageResponse(/** @scrutinizer ignore-type */ $filesystem->read($path), $mime_type, $filename);
Loading history...
94
        } catch (FileNotFoundException $ex) {
95
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
96
        }
97
    }
98
99
    /**
100
     * Send a thumbnail.
101
     *
102
     * @param FilesystemInterface $filesystem
103
     * @param string              $path
104
     * @param int                 $width
105
     * @param int                 $height
106
     * @param string              $fit
107
     *
108
     *
109
     * @return ResponseInterface
110
     */
111
    public function thumbnailResponse(
112
        FilesystemInterface $filesystem,
113
        string $path,
114
        int $width,
115
        int $height,
116
        string $fit
117
    ): ResponseInterface {
118
        try {
119
            $image = $this->imageManager()->make($filesystem->readStream($path));
120
            $image = $this->autorotateImage($image);
121
            $image = $this->resizeImage($image, $width, $height, $fit);
122
123
            $format  = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg';
124
            $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY);
125
            $data    = (string) $image->encode($format, $quality);
126
127
            return $this->imageResponse($data, $image->mime(), '');
128
        } catch (NotReadableException $ex) {
129
            return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION))
130
                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
131
        } catch (FileNotFoundException $ex) {
132
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
133
        } catch (Throwable $ex) {
134
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
135
                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
136
        }
137
    }
138
139
    /**
140
     * Create a full-size version of an image.
141
     *
142
     * @param MediaFile $media_file
143
     * @param bool      $add_watermark
144
     * @param bool      $download
145
     *
146
     * @return ResponseInterface
147
     */
148
    public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface
149
    {
150
        $filesystem = Registry::filesystem()->media($media_file->media()->tree());
151
        $path   = $media_file->filename();
152
153
        if (!$add_watermark || !$media_file->isImage()) {
154
            return $this->fileResponse($filesystem, $path, $download);
155
        }
156
157
        try {
158
            $image     = $this->imageManager()->make($filesystem->readStream($path));
159
            $image     = $this->autorotateImage($image);
160
            $watermark = $this->createWatermark($image->width(), $image->height(), $media_file);
161
            $image     = $this->addWatermark($image, $watermark);
162
            $filename  = $download ? basename($path) : '';
163
            $format    = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg';
164
            $quality   = $this->extractImageQuality($image, static::GD_DEFAULT_IMAGE_QUALITY);
165
            $data      = (string) $image->encode($format, $quality);
166
167
            return $this->imageResponse($data, $image->mime(), $filename);
168
        } catch (NotReadableException $ex) {
169
            return $this->replacementImageResponse(pathinfo($path, PATHINFO_EXTENSION))
170
                ->withHeader('X-Image-Exception', $ex->getMessage());
171
        } catch (FileNotFoundException $ex) {
172
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
173
        } catch (Throwable $ex) {
174
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
175
                ->withHeader('X-Image-Exception', $ex->getMessage());
176
        }
177
    }
178
179
    /**
180
     * Create a smaller version of an image.
181
     *
182
     * @param MediaFile $media_file
183
     * @param int       $width
184
     * @param int       $height
185
     * @param string    $fit
186
     * @param bool      $add_watermark
187
     *
188
     * @return ResponseInterface
189
     */
190
    public function mediaFileThumbnailResponse(
191
        MediaFile $media_file,
192
        int $width,
193
        int $height,
194
        string $fit,
195
        bool $add_watermark
196
    ): ResponseInterface {
197
        // Where are the images stored.
198
        $filesystem = Registry::filesystem()->media($media_file->media()->tree());
199
200
        // Where is the image stored in the filesystem.
201
        $path = $media_file->filename();
202
203
        try {
204
            $mime_type = $filesystem->getMimetype($path);
205
206
            $key = implode(':', [
207
                $media_file->media()->tree()->name(),
208
                $path,
209
                $filesystem->getTimestamp($path),
210
                (string) $width,
211
                (string) $height,
212
                $fit,
213
                (string) $add_watermark,
214
            ]);
215
216
            $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string {
217
                $image = $this->imageManager()->make($filesystem->readStream($path));
218
                $image = $this->autorotateImage($image);
219
                $image = $this->resizeImage($image, $width, $height, $fit);
220
221
                if ($add_watermark) {
222
                    $watermark = $this->createWatermark($image->width(), $image->height(), $media_file);
223
                    $image     = $this->addWatermark($image, $watermark);
224
                }
225
226
                $format  = static::INTERVENTION_FORMATS[$image->mime()] ?? 'jpg';
227
                $quality = $this->extractImageQuality($image, static::GD_DEFAULT_THUMBNAIL_QUALITY);
228
229
                return (string) $image->encode($format, $quality);
230
            };
231
232
            // Images and Responses both contain resources - which cannot be serialized.
233
            // So cache the raw image data.
234
            $data = Registry::cache()->file()->remember($key, $closure, static::THUMBNAIL_CACHE_TTL);
235
236
            return $this->imageResponse($data, $mime_type, '');
0 ignored issues
show
It seems like $mime_type can also be of type false; however, parameter $mime_type of Fisharebest\Webtrees\Fac...actory::imageResponse() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

236
            return $this->imageResponse($data, /** @scrutinizer ignore-type */ $mime_type, '');
Loading history...
237
        } catch (NotReadableException $ex) {
238
            return $this->replacementImageResponse('.' . pathinfo($path, PATHINFO_EXTENSION))
239
                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
240
        } catch (FileNotFoundException $ex) {
241
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_NOT_FOUND);
242
        } catch (Throwable $ex) {
243
            return $this->replacementImageResponse((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
244
                ->withHeader('X-Thumbnail-Exception', get_class($ex) . ': ' . $ex->getMessage());
245
        }
246
    }
247
248
    /**
249
     * Does a full-sized image need a watermark?
250
     *
251
     * @param MediaFile     $media_file
252
     * @param UserInterface $user
253
     *
254
     * @return bool
255
     */
256
    public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
257
    {
258
        $tree = $media_file->media()->tree();
259
260
        return Auth::accessLevel($tree, $user) > $tree->getPreference('SHOW_NO_WATERMARK');
261
    }
262
263
    /**
264
     * Does a thumbnail image need a watermark?
265
     *
266
     * @param MediaFile     $media_file
267
     * @param UserInterface $user
268
     *
269
     * @return bool
270
     */
271
    public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
272
    {
273
        return $this->fileNeedsWatermark($media_file, $user);
274
    }
275
276
    /**
277
     * Create a watermark image, perhaps specific to a media-file.
278
     *
279
     * @param int       $width
280
     * @param int       $height
281
     * @param MediaFile $media_file
282
     *
283
     * @return Image
284
     */
285
    public function createWatermark(int $width, int $height, MediaFile $media_file): Image
286
    {
287
        return $this->imageManager()
288
            ->make(Webtrees::ROOT_DIR . static::WATERMARK_FILE)
289
            ->resize($width, $height, static function (Constraint $constraint) {
290
                $constraint->aspectRatio();
291
            });
292
    }
293
294
    /**
295
     * Add a watermark to an image.
296
     *
297
     * @param Image $image
298
     * @param Image $watermark
299
     *
300
     * @return Image
301
     */
302
    public function addWatermark(Image $image, Image $watermark): Image
303
    {
304
        return $image->insert($watermark, 'center');
305
    }
306
307
    /**
308
     * Send a replacement image, to replace one that could not be found or created.
309
     *
310
     * @param string $text HTTP status code or file extension
311
     *
312
     * @return ResponseInterface
313
     */
314
    public function replacementImageResponse(string $text): ResponseInterface
315
    {
316
        // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing.
317
        $svg = view('errors/image-svg', ['status' => $text]);
318
319
        // We can't send the actual status code, as browsers won't show images with 4xx/5xx.
320
        return response($svg, StatusCodeInterface::STATUS_OK, [
321
            'content-type' => 'image/svg+xml',
322
        ]);
323
    }
324
325
    /**
326
     * @param string $data
327
     * @param string $mime_type
328
     * @param string $filename
329
     *
330
     * @return ResponseInterface
331
     */
332
    protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface
333
    {
334
        if ($mime_type === 'image/svg+xml' && str_contains($data, '<script')) {
335
            return $this->replacementImageResponse('XSS')
336
                ->withHeader('X-Image-Exception', 'SVG image blocked due to XSS.');
337
        }
338
339
        // HTML files may contain javascript and iframes, so use content-security-policy to disable them.
340
        $response = response($data)
341
            ->withHeader('content-type', $mime_type)
342
            ->withHeader('content-security-policy', 'script-src none;frame-src none');
343
344
        if ($filename === '') {
345
            return $response;
346
        }
347
348
        return $response
349
            ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(basename($filename), '"'));
350
    }
351
352
    /**
353
     * @return ImageManager
354
     * @throws RuntimeException
355
     */
356
    protected function imageManager(): ImageManager
357
    {
358
        foreach (static::INTERVENTION_DRIVERS as $driver) {
359
            if (extension_loaded($driver)) {
360
                return new ImageManager(['driver' => $driver]);
361
            }
362
        }
363
364
        throw new RuntimeException('No PHP graphics library is installed.  Need Imagick or GD');
365
    }
366
367
    /**
368
     * Apply EXIF rotation to an image.
369
     *
370
     * @param Image $image
371
     *
372
     * @return Image
373
     */
374
    protected function autorotateImage(Image $image): Image
375
    {
376
        try {
377
            // Auto-rotate using EXIF information.
378
            return $image->orientate();
379
        } catch (NotSupportedException $ex) {
380
            // If we can't auto-rotate the image, then don't.
381
            return $image;
382
        }
383
    }
384
385
    /**
386
     * Resize an image.
387
     *
388
     * @param Image  $image
389
     * @param int    $width
390
     * @param int    $height
391
     * @param string $fit
392
     *
393
     * @return Image
394
     */
395
    protected function resizeImage(Image $image, int $width, int $height, string $fit): Image
396
    {
397
        switch ($fit) {
398
            case 'crop':
399
                return $image->fit($width, $height);
400
            case 'contain':
401
                return $image->resize($width, $height, static function (Constraint $constraint) {
402
                    $constraint->aspectRatio();
403
                    $constraint->upsize();
404
                });
405
        }
406
407
        return $image;
408
    }
409
410
    /**
411
     * Extract the quality/compression parameter from an image.
412
     *
413
     * @param Image $image
414
     * @param int   $default
415
     *
416
     * @return int
417
     */
418
    protected function extractImageQuality(Image $image, int $default): int
419
    {
420
        $core = $image->getCore();
421
422
        if ($core instanceof Imagick) {
423
            return $core->getImageCompressionQuality() ?: $default;
424
        }
425
426
        return $default;
427
    }
428
}
429