Passed
Push — master ( 602c43...6577bf )
by Greg
06:13
created

ImageFactory::addWatermark()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 3
rs 10
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2020 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 <http://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\Factory;
27
use Fisharebest\Webtrees\MediaFile;
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 pathinfo;
45
use function response;
46
use function strlen;
47
use function view;
48
49
use const PATHINFO_EXTENSION;
50
51
/**
52
 * Make an image (from another image).
53
 */
54
class 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),
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type false; however, parameter $string of strlen() 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

91
                'Content-Length' => (string) strlen(/** @scrutinizer ignore-type */ $data),
Loading history...
92
            ];
93
94
            if ($download) {
95
                $headers['Content-Disposition'] = 'attachment; filename="' . addcslashes(basename($path), '"');
96
            }
97
98
            return response($data, StatusCodeInterface::STATUS_OK, $headers);
0 ignored issues
show
Bug introduced by
$headers of type array<string,false|string> is incompatible with the type string[] expected by parameter $headers of response(). ( Ignorable by Annotation )

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

98
            return response($data, StatusCodeInterface::STATUS_OK, /** @scrutinizer ignore-type */ $headers);
Loading history...
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 = Factory::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 = Factory::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 = Factory::cache()->file()->remember($key, $closure, static::THUMBNAIL_CACHE_TTL);
243
244
            return $this->imageResponse($data, $mime_type, '');
0 ignored issues
show
Bug introduced by
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

244
            return $this->imageResponse($data, /** @scrutinizer ignore-type */ $mime_type, '');
Loading history...
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();
425
        }
426
427
        return $default;
428
    }
429
}
430