Issues (165)

app/Factories/ImageFactory.php (2 issues)

Labels
Severity
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2023 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\Gif\Exceptions\NotReadableException;
32
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
33
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
34
use Intervention\Image\ImageManager;
35
use Intervention\Image\Interfaces\ImageInterface;
36
use InvalidArgumentException;
37
use League\Flysystem\FilesystemException;
38
use League\Flysystem\FilesystemOperator;
39
use League\Flysystem\UnableToReadFile;
40
use League\Flysystem\UnableToRetrieveMetadata;
41
use Psr\Http\Message\ResponseInterface;
42
use RuntimeException;
43
use Throwable;
44
45
use function addcslashes;
46
use function basename;
47
use function extension_loaded;
48
use function get_class;
49
use function implode;
50
use function pathinfo;
51
use function response;
52
use function str_contains;
53
use function view;
54
55
use const PATHINFO_EXTENSION;
56
57
/**
58
 * Make an image (from another image).
59
 */
60
class ImageFactory implements ImageFactoryInterface
61
{
62
    // Imagick can detect the quality setting for images.  GD cannot.
63
    protected const GD_DEFAULT_IMAGE_QUALITY     = 90;
64
    protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70;
65
66
    protected const WATERMARK_FILE = 'resources/img/watermark.png';
67
68
    protected const THUMBNAIL_CACHE_TTL = 8640000;
69
70
    public const SUPPORTED_FORMATS = [
71
        'image/jpeg' => 'jpg',
72
        'image/png'  => 'png',
73
        'image/gif'  => 'gif',
74
        'image/tiff' => 'tif',
75
        'image/bmp'  => 'bmp',
76
        'image/webp' => 'webp',
77
    ];
78
79
    /**
80
     * Send the original file - either inline or as a download.
81
     */
82
    public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface
83
    {
84
        try {
85
            try {
86
                $mime_type = $filesystem->mimeType(path: $path);
87
            } catch (UnableToRetrieveMetadata) {
88
                $mime_type = Mime::DEFAULT_TYPE;
89
            }
90
91
            $filename = $download ? addcslashes(string: basename(path: $path), characters: '"') : '';
92
93
            return $this->imageResponse(data: $filesystem->read(location: $path), mime_type: $mime_type, filename: $filename);
94
        } catch (UnableToReadFile | FilesystemException $ex) {
95
            return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
96
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
97
        }
98
    }
99
100
    /**
101
     * Send a thumbnail.
102
     */
103
    public function thumbnailResponse(
104
        FilesystemOperator $filesystem,
105
        string $path,
106
        int $width,
107
        int $height,
108
        string $fit
109
    ): ResponseInterface {
110
        try {
111
            $mime_type = $filesystem->mimeType(path: $path);
112
            $image     = $this->imageManager()->read(input: $filesystem->readStream($path));
113
            $image     = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit);
114
            $quality   = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY);
115
            $data      = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString();
116
117
            return $this->imageResponse(data: $data, mime_type: $mime_type, filename: '');
118
        } catch (FilesystemException | UnableToReadFile $ex) {
119
            return $this
120
                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
121
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
122
        } catch (RuntimeException $ex) {
123
            return $this
124
                ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION))
0 ignored issues
show
Are you sure pathinfo($path, PATHINFO_EXTENSION) of type array|string can be used in concatenation? ( Ignorable by Annotation )

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

124
                ->replacementImageResponse(text: '.' . /** @scrutinizer ignore-type */ pathinfo(path: $path, flags: PATHINFO_EXTENSION))
Loading history...
125
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
126
        } catch (Throwable $ex) {
127
            return $this
128
                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
129
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
130
        }
131
    }
132
133
    /**
134
     * Create a full-size version of an image.
135
     */
136
    public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface
137
    {
138
        $filesystem = $media_file->media()->tree()->mediaFilesystem();
139
        $path       = $media_file->filename();
140
141
        if (!$add_watermark || !$media_file->isImage()) {
142
            return $this->fileResponse(filesystem: $filesystem, path: $path, download: $download);
143
        }
144
145
        try {
146
            $mime_type = $media_file->mimeType();
147
            $image     = $this->imageManager()->read(input: $filesystem->readStream($path));
148
            $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file);
149
            $image     = $this->addWatermark(image: $image, watermark: $watermark);
150
            $filename  = $download ? basename(path: $path) : '';
151
            $quality   = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_IMAGE_QUALITY);
152
            $data      = $image->encodeByMediaType(type: $mime_type, quality:  $quality)->toString();
153
154
            return $this->imageResponse(data: $data, mime_type: $mime_type, filename: $filename);
155
        } catch (NotReadableException $ex) {
156
            return $this->replacementImageResponse(text: pathinfo(path: $path, flags: PATHINFO_EXTENSION))
157
                ->withHeader('x-image-exception', $ex->getMessage());
158
        } catch (FilesystemException | UnableToReadFile $ex) {
159
            return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
160
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
161
        } catch (Throwable $ex) {
162
            return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
163
                ->withHeader('x-image-exception', $ex->getMessage());
164
        }
165
    }
166
167
    /**
168
     * Create a smaller version of an image.
169
     */
170
    public function mediaFileThumbnailResponse(
171
        MediaFile $media_file,
172
        int $width,
173
        int $height,
174
        string $fit,
175
        bool $add_watermark
176
    ): ResponseInterface {
177
        // Where are the images stored.
178
        $filesystem = $media_file->media()->tree()->mediaFilesystem();
179
180
        // Where is the image stored in the filesystem.
181
        $path = $media_file->filename();
182
183
        try {
184
            $mime_type = $filesystem->mimeType(path: $path);
185
186
            $key = implode(separator: ':', array: [
187
                $media_file->media()->tree()->name(),
188
                $path,
189
                $filesystem->lastModified(path: $path),
190
                (string) $width,
191
                (string) $height,
192
                $fit,
193
                (string) $add_watermark,
194
            ]);
195
196
            $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string {
197
                $image = $this->imageManager()->read(input: $filesystem->readStream($path));
198
                $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit);
199
200
                if ($add_watermark) {
201
                    $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file);
202
                    $image     = $this->addWatermark(image: $image, watermark: $watermark);
203
                }
204
205
                $quality = $this->extractImageQuality(image: $image, default:  static::GD_DEFAULT_THUMBNAIL_QUALITY);
206
207
                return $image->encodeByMediaType(type: $media_file->mimeType(), quality: $quality)->toString();
208
            };
209
210
            // Images and Responses both contain resources - which cannot be serialized.
211
            // So cache the raw image data.
212
            $data = Registry::cache()->file()->remember(key: $key, closure: $closure, ttl: static::THUMBNAIL_CACHE_TTL);
213
214
            return $this->imageResponse(data: $data, mime_type:  $mime_type, filename:  '');
215
        } catch (NotReadableException $ex) {
216
            return $this
217
                ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags:  PATHINFO_EXTENSION))
0 ignored issues
show
Are you sure pathinfo($path, PATHINFO_EXTENSION) of type array|string can be used in concatenation? ( Ignorable by Annotation )

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

217
                ->replacementImageResponse(text: '.' . /** @scrutinizer ignore-type */ pathinfo(path: $path, flags:  PATHINFO_EXTENSION))
Loading history...
218
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
219
        } catch (FilesystemException | UnableToReadFile $ex) {
220
            return $this
221
                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
222
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
223
        } catch (Throwable $ex) {
224
            return $this
225
                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
226
                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
227
        }
228
    }
229
230
    /**
231
     * Does a full-sized image need a watermark?
232
     */
233
    public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
234
    {
235
        $tree = $media_file->media()->tree();
236
237
        return Auth::accessLevel(tree: $tree, user: $user) > (int) $tree->getPreference(setting_name: 'SHOW_NO_WATERMARK');
238
    }
239
240
    /**
241
     * Does a thumbnail image need a watermark?
242
     */
243
    public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
244
    {
245
        return $this->fileNeedsWatermark(media_file: $media_file, user:  $user);
246
    }
247
248
    /**
249
     * Create a watermark image, perhaps specific to a media-file.
250
     */
251
    public function createWatermark(int $width, int $height, MediaFile $media_file): ImageInterface
252
    {
253
        return $this->imageManager()
254
            ->read(input: Webtrees::ROOT_DIR . static::WATERMARK_FILE)
255
            ->contain(width: $width, height: $height);
256
    }
257
258
    /**
259
     * Add a watermark to an image.
260
     */
261
    public function addWatermark(ImageInterface $image, ImageInterface $watermark): ImageInterface
262
    {
263
        return $image->place(element: $watermark, position:  'center');
264
    }
265
266
    /**
267
     * Send a replacement image, to replace one that could not be found or created.
268
     */
269
    public function replacementImageResponse(string $text): ResponseInterface
270
    {
271
        // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing.
272
        $svg = view(name: 'errors/image-svg', data: ['status' => $text]);
273
274
        // We can't send the actual status code, as browsers won't show images with 4xx/5xx.
275
        return response(content: $svg, code: StatusCodeInterface::STATUS_OK, headers: [
276
            'content-type' => 'image/svg+xml',
277
        ]);
278
    }
279
280
    /**
281
     * Create a response from image data.
282
     */
283
    protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface
284
    {
285
        if ($mime_type === 'image/svg+xml' && str_contains(haystack: $data, needle: '<script')) {
286
            return $this->replacementImageResponse(text: 'XSS')
287
                ->withHeader('x-image-exception', 'SVG image blocked due to XSS.');
288
        }
289
290
        // HTML files may contain javascript and iframes, so use content-security-policy to disable them.
291
        $response = response($data)
292
            ->withHeader('content-type', $mime_type)
293
            ->withHeader('content-security-policy', 'script-src none;frame-src none');
294
295
        if ($filename === '') {
296
            return $response;
297
        }
298
299
        return $response
300
            ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(string: basename(path: $filename), characters: '"'));
301
    }
302
303
    /**
304
     * Choose an image library, based on what is installed.
305
     */
306
    protected function imageManager(): ImageManager
307
    {
308
        if (extension_loaded(extension: 'imagick')) {
309
            return new ImageManager(driver: new ImagickDriver());
310
        }
311
312
        if (extension_loaded(extension: 'gd')) {
313
            return new ImageManager(driver: new GdDriver());
314
        }
315
316
        throw new RuntimeException(message: 'No PHP graphics library is installed.  Need Imagick or GD');
317
    }
318
319
    /**
320
     * Resize an image.
321
     */
322
    protected function resizeImage(ImageInterface $image, int $width, int $height, string $fit): ImageInterface
323
    {
324
        return match ($fit) {
325
            'crop'    => $image->cover(width: $width, height: $height),
326
            'contain' => $image->scale(width: $width, height: $height),
327
            default   => throw new InvalidArgumentException(message: 'Unknown fit type: ' . $fit),
328
        };
329
    }
330
331
    /**
332
     * Extract the quality/compression parameter from an image.
333
     */
334
    protected function extractImageQuality(ImageInterface $image, int $default): int
335
    {
336
        $native = $image->core()->native();
337
338
        if ($native instanceof Imagick) {
339
            return $native->getImageCompressionQuality();
340
        }
341
342
        return $default;
343
    }
344
}
345